diff --git a/.claude/commands/termstack.md b/.claude/commands/termstack.md new file mode 100644 index 0000000..542519d --- /dev/null +++ b/.claude/commands/termstack.md @@ -0,0 +1,48 @@ +--- +name: termstack +description: Generate a TermStack YAML configuration for a TUI that browses APIs or displays data +arguments: + - name: prompt + description: Description of what TUI you want to create (e.g., "browse GitHub repos", "display weather data") + required: true +allowed-tools: + - Read + - Write + - WebFetch + - Bash +--- + +# TermStack YAML Generator + +Generate a TermStack TUI configuration based on the user's requirements. + +## Instructions + +1. **Understand the request**: Parse what API or data source the user wants to browse +2. **Research the API**: If needed, use WebFetch to check the API documentation or test endpoints +3. **Generate the YAML**: Create a complete, working TermStack configuration +4. **Save the file**: Write to `examples/` directory with a descriptive name +5. **Test the config**: Run `cargo run -- examples/filename.yaml` to validate + +## Key Rules + +- Use `{{ variable }}` for template variables (NOT `{{ page.variable }}`) +- Context variables passed via `next.context` are accessed directly by key name +- Always include proper JSONPath for `items` field to extract arrays +- Add meaningful column styling with colors +- Include both `next` (for Enter) and `actions` (for shortcuts) + +## Reference + +Load the skill file for complete documentation: +- `.claude/skills/termstack-yaml-generator/SKILL.md` + +## Examples + +Reference working examples: +- `examples/dog-api.yaml` - REST API browser +- `examples/kubernetes-cli.yaml` - CLI-based k8s dashboard + +## Output + +Generate the YAML and save it to `examples/[name].yaml`, then provide instructions to run it. diff --git a/.claude/skills/termstack-yaml-generator/SKILL.md b/.claude/skills/termstack-yaml-generator/SKILL.md new file mode 100644 index 0000000..618d265 --- /dev/null +++ b/.claude/skills/termstack-yaml-generator/SKILL.md @@ -0,0 +1,845 @@ +--- +name: termstack-yaml-generator +description: This skill should be used when the user wants to create a TermStack TUI configuration, generate a YAML file for browsing APIs, create a terminal dashboard, or build a config-driven terminal UI. Use this when users mention TermStack, TUI configuration, API browser, terminal dashboard, or want to create YAML configs for data visualization in the terminal. +version: 2.0.0 +--- + +# TermStack YAML Configuration Generator + +This skill helps generate TermStack YAML configuration files for creating terminal user interfaces (TUIs) that browse APIs, display data in tables, stream logs, and navigate between pages. + +## What is TermStack? + +TermStack is a config-driven Terminal User Interface (TUI) framework. You define pages, data sources, views, and actions in YAML - no coding required. It supports: + +- **HTTP API calls** with query parameters and headers +- **CLI command execution** with arguments +- **Script execution** for custom data processing +- **Stream adapters** for real-time data (logs, websocket) +- **Table views** with sortable, styled columns +- **Text/YAML views** for detailed data +- **Logs views** with filtering and search +- **Multi-page navigation** with context passing +- **Conditional navigation** based on data values +- **Actions** triggered by keyboard shortcuts +- **Multiple data sources** with merge capabilities +- **Column transforms** with Tera templates +- **Validation rules** for data integrity + +## YAML Configuration Structure + +```yaml +version: v1 + +app: + name: "App Name" + description: "App description" + theme: "default" + +globals: + api_base: "https://api.example.com" + # Variables accessible in all templates as {{ variable_name }} + +start: page_name # First page to show + +pages: + page_name: + title: "Page Title" + data: + adapter: http # or "cli", "script", "stream" + url: "{{ api_base }}/endpoint" + method: GET + params: + key: "value" + headers: + Accept: "application/json" + items: "$.data[*]" # JSONPath to extract array items + view: + type: table # or "text", "logs" + columns: + - path: "$.field" + display: "Column Name" + width: 20 + style: + - default: true + color: cyan + next: + page: detail_page + context: + item_id: "$.id" + actions: + - key: "d" + name: "Details" + page: "detail_page" + context: + item_id: "$.id" +``` + +## Key Concepts + +### 1. Data Adapters + +#### HTTP Adapter +```yaml +data: + adapter: http + url: "{{ api_base }}/users" + method: GET # GET, POST, PUT, DELETE, PATCH + params: + limit: 10 + status: "active" + headers: + Authorization: "Bearer {{ token }}" + Accept: "application/json" + items: "$.data[*]" # JSONPath for array extraction + timeout: "30s" # Supports: s, m, h (e.g., "5m", "1h") + refresh_interval: "5m" # Auto-refresh data +``` + +#### CLI Adapter +```yaml +data: + adapter: cli + command: "kubectl" + args: ["get", "pods", "-n", "{{ namespace }}", "-o", "json"] + items: "$.items[*]" + timeout: "10s" + refresh_interval: "30s" +``` + +#### Script Adapter +Execute external scripts for custom data processing: +```yaml +data: + adapter: script + path: "./scripts/process_data.sh" + args: ["--env", "{{ environment }}"] + items: "$[*]" + timeout: "1m" +``` + +#### Stream Adapter +For real-time streaming data (logs, websocket, file tailing): +```yaml +data: + adapter: stream + source: websocket # or "file", "command" + url: "wss://api.example.com/stream" # For websocket + # OR + # path: "/var/log/app.log" # For file tailing + # OR + # command: "tail" # For command streaming + # args: ["-f", "/var/log/app.log"] + buffer_size: 1000 # Max lines to keep in buffer + buffer_time: "5s" # Time window for buffering + follow: true # Auto-scroll to new lines +``` + +### 2. View Types + +#### Table View +Display data in columns with sorting and styling: +```yaml +view: + type: table + columns: + - path: "$.name" + display: "Name" + width: 30 + style: + - default: true + color: cyan + bold: true + - path: "$.status" + display: "Status" + width: 15 + style: + - condition: "{{ value == 'active' }}" + color: green + - condition: "{{ value == 'inactive' }}" + color: red + - default: true + color: gray + - path: "$.created_at" + display: "Age" + width: 15 + transform: "{{ value | timeago }}" # Tera filter + style: + - default: true + color: yellow + - path: "$.size" + display: "Size" + width: 12 + transform: "{{ value | filesizeformat }}" # Format bytes + style: + - default: true + color: blue +``` + +#### Text View +Display single objects as formatted text: +```yaml +view: + type: text + syntax: yaml # or json, xml, toml, etc. +``` + +#### Logs View +Display streaming logs with filtering: +```yaml +view: + type: logs + filters: + - name: "Errors" + pattern: "ERROR|error|Error" + color: red + - name: "Warnings" + pattern: "WARN|warn|Warning" + color: yellow + - name: "Info" + pattern: "INFO|info" + color: cyan + line_numbers: true + wrap: true + follow: true # Auto-scroll to new lines +``` + +### 3. Navigation + +#### Simple Navigation (Enter key) +```yaml +next: + page: detail_page + context: + item_id: "$.id" + item_name: "$.name" +``` + +#### Conditional Navigation +Navigate to different pages based on data values: +```yaml +next: + - condition: "{{ row.type == 'folder' }}" + page: folder_view + context: + folder_id: "$.id" + - condition: "{{ row.type == 'file' }}" + page: file_view + context: + file_id: "$.id" + - default: true + page: default_view +``` + +### 4. Actions + +Actions are triggered by pressing `a` then the action key: + +```yaml +actions: + # Execute command + - key: "d" + name: "Delete" + description: "Delete this item" + confirm: "Are you sure you want to delete {{ name }}?" + command: "curl" + args: ["-X", "DELETE", "{{ api_base }}/items/{{ id }}"] + refresh: true + + # Navigate to page + - key: "v" + name: "View Details" + page: "detail_page" + context: + item_id: "$.id" + + # Open in external app + - key: "o" + name: "Open in Browser" + command: "open" + args: ["{{ html_url }}"] + + # Builtin actions + - key: "y" + name: "YAML View" + builtin: yaml_view + + - key: "h" + name: "Help" + builtin: help + + - key: "s" + name: "Search" + builtin: search + + - key: "r" + name: "Refresh" + builtin: refresh + + - key: "b" + name: "Back" + builtin: back + + - key: "q" + name: "Quit" + builtin: quit +``` + +### 5. Multiple Data Sources + +Combine data from multiple sources: + +```yaml +data: + sources: + - name: users + adapter: http + url: "{{ api_base }}/users" + items: "$.data[*]" + + - name: stats + adapter: http + url: "{{ api_base }}/stats" + items: "$.data[*]" + optional: true # Don't fail if this source fails + + merge: true # Merge all sources into single dataset + +# Access in columns +view: + type: table + columns: + - path: "$.name" + display: "User" + source: users # From specific source + - path: "$.count" + display: "Stats" + source: stats +``` + +### 6. Context Variables + +When navigating between pages, context is passed via the `context` block: + +```yaml +# Source page +next: + page: detail + context: + user_id: "$.id" # JSONPath extracts from selected row + user_name: "$.name" + +# Target page - access as {{ user_id }} and {{ user_name }} +detail: + title: "User: {{ user_name }}" + data: + url: "{{ api_base }}/users/{{ user_id }}" +``` + +**IMPORTANT:** Context variables are accessed directly by their key name (e.g., `{{ user_id }}`), NOT by the source page name (e.g., NOT `{{ users.user_id }}`). + +### 7. Styling + +Available colors: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, `gray` + +Style modifiers: `bold`, `dim` + +```yaml +style: + - condition: "{{ value > 100 }}" + color: green + bold: true + - condition: "{{ value < 0 }}" + color: red + - default: true + color: white +``` + +### 8. Column Transforms + +Use Tera templates to transform column values: + +```yaml +columns: + - path: "$.created_at" + display: "Age" + transform: "{{ value | timeago }}" # "2 hours ago" + + - path: "$.bytes" + display: "Size" + transform: "{{ value | filesizeformat }}" # "1.5 MB" + + - path: "$.status" + display: "Status" + transform: "{{ value | upper }}" # ACTIVE + + - path: "$.price" + display: "Price" + transform: "${{ value | round(precision=2) }}" # $19.99 + + - path: "$.tags" + display: "Tags" + transform: "{{ value | join(sep=', ') }}" # tag1, tag2 +``` + +Available Tera filters: +- `timeago` - Convert timestamp to relative time +- `filesizeformat` - Format bytes to human-readable size +- `upper`, `lower`, `capitalize` - Case conversion +- `round` - Round numbers +- `join` - Join arrays +- `status_color` - Color code status values + +### 9. Validation Rules + +Add validation to ensure data integrity: + +```yaml +validation: + rules: + - field: "$.email" + type: email + message: "Invalid email format" + + - field: "$.age" + type: range + min: 0 + max: 120 + message: "Age must be between 0 and 120" + + - field: "$.username" + type: regex + pattern: "^[a-zA-Z0-9_]+$" + message: "Username can only contain letters, numbers, and underscores" + + - field: "$.status" + type: enum + values: ["active", "inactive", "pending"] + message: "Status must be active, inactive, or pending" +``` + +### 10. JSONPath Reference + +Common patterns: +- `$[*]` - All items in root array +- `$.data[*]` - All items in `data` array +- `$.items[*]` - All items in `items` array +- `$.attributes.name` - Nested field access +- `$.data` - Single object (for detail views) +- `$..name` - Recursive descent (all `name` fields) +- `$[0]` - First item +- `$[-1]` - Last item +- `$[?(@.status == 'active')]` - Filter items + +## Complete Examples + +### Example 1: Dog API Browser + +```yaml +version: v1 + +app: + name: "Dog Breeds Browser" + description: "Explore dog breeds and facts" + theme: "default" + +globals: + api_base: "https://dogapi.dog/api/v2" + +start: breeds + +pages: + breeds: + title: "Dog Breeds" + data: + adapter: http + url: "{{ api_base }}/breeds" + method: GET + headers: + Accept: "application/json" + items: "$.data[*]" + view: + type: table + columns: + - path: "$.attributes.name" + display: "Breed" + width: 30 + style: + - default: true + color: cyan + bold: true + - path: "$.attributes.life.min" + display: "Min Life" + width: 10 + style: + - default: true + color: green + - path: "$.attributes.life.max" + display: "Max Life" + width: 10 + style: + - default: true + color: green + - path: "$.attributes.hypoallergenic" + display: "Hypo" + width: 6 + style: + - condition: "{{ value == 'true' }}" + color: yellow + bold: true + - default: true + color: gray + next: + page: breed_detail + context: + breed_id: "$.id" + breed_name: "$.attributes.name" + actions: + - key: "f" + name: "View Facts" + page: "facts" + + breed_detail: + title: "{{ breed_name }}" + data: + adapter: http + url: "{{ api_base }}/breeds/{{ breed_id }}" + method: GET + headers: + Accept: "application/json" + items: "$.data" + view: + type: table + columns: + - path: "$.attributes.name" + display: "Name" + width: 30 + - path: "$.attributes.description" + display: "Description" + width: 80 + + facts: + title: "Dog Facts" + data: + adapter: http + url: "{{ api_base }}/facts" + method: GET + headers: + Accept: "application/json" + items: "$.data[*]" + view: + type: table + columns: + - path: "$.attributes.body" + display: "Fact" + width: 100 + style: + - default: true + color: yellow +``` + +### Example 2: Kubernetes Dashboard with Logs + +```yaml +version: v1 + +app: + name: "Kubernetes Dashboard" + description: "Browse pods and view logs" + theme: "default" + +globals: + namespace: "default" + +start: pods + +pages: + pods: + title: "Pods in {{ namespace }}" + data: + adapter: cli + command: "kubectl" + args: ["get", "pods", "-n", "{{ namespace }}", "-o", "json"] + items: "$.items[*]" + refresh_interval: "10s" + view: + type: table + columns: + - path: "$.metadata.name" + display: "Name" + width: 40 + style: + - default: true + color: cyan + - path: "$.status.phase" + display: "Status" + width: 15 + style: + - condition: "{{ value == 'Running' }}" + color: green + - condition: "{{ value == 'Pending' }}" + color: yellow + - condition: "{{ value == 'Failed' }}" + color: red + - default: true + color: white + - path: "$.metadata.creationTimestamp" + display: "Age" + width: 15 + transform: "{{ value | timeago }}" + next: + page: pod_detail + context: + pod_name: "$.metadata.name" + actions: + - key: "l" + name: "View Logs" + page: "pod_logs" + context: + pod_name: "$.metadata.name" + + pod_detail: + title: "Pod: {{ pod_name }}" + data: + adapter: cli + command: "kubectl" + args: ["get", "pod", "{{ pod_name }}", "-n", "{{ namespace }}", "-o", "json"] + items: "$.data" + view: + type: text + syntax: yaml + + pod_logs: + title: "Logs: {{ pod_name }}" + data: + adapter: stream + source: command + command: "kubectl" + args: ["logs", "-f", "{{ pod_name }}", "-n", "{{ namespace }}"] + buffer_size: 1000 + follow: true + view: + type: logs + filters: + - name: "Errors" + pattern: "ERROR|error|Error" + color: red + - name: "Warnings" + pattern: "WARN|warn|Warning" + color: yellow + - name: "Info" + pattern: "INFO|info" + color: cyan + line_numbers: true + wrap: true +``` + +### Example 3: File Browser with Conditional Navigation + +```yaml +version: v1 + +app: + name: "File Browser" + description: "Browse files and directories" + theme: "default" + +globals: + base_path: "/Users/user/projects" + +start: directory + +pages: + directory: + title: "{{ current_path | default(value=base_path) }}" + data: + adapter: cli + command: "ls" + args: ["-la", "{{ current_path | default(value=base_path) }}"] + items: "$[*]" + view: + type: table + columns: + - path: "$.name" + display: "Name" + width: 40 + style: + - condition: "{{ row.type == 'dir' }}" + color: blue + bold: true + - default: true + color: white + - path: "$.size" + display: "Size" + width: 12 + transform: "{{ value | filesizeformat }}" + - path: "$.modified" + display: "Modified" + width: 20 + transform: "{{ value | timeago }}" + next: + - condition: "{{ row.type == 'dir' }}" + page: directory + context: + current_path: "$.path" + - condition: "{{ row.type == 'file' }}" + page: file_content + context: + file_path: "$.path" + - default: true + page: directory + + file_content: + title: "{{ file_path }}" + data: + adapter: cli + command: "cat" + args: ["{{ file_path }}"] + view: + type: text + syntax: auto # Auto-detect from file extension +``` + +### Example 4: REST API with Multiple Data Sources + +```yaml +version: v1 + +app: + name: "User Dashboard" + description: "View users with stats" + theme: "default" + +globals: + api_base: "https://api.example.com" + +start: users + +pages: + users: + title: "Users with Activity" + data: + sources: + - name: users + adapter: http + url: "{{ api_base }}/users" + items: "$.data[*]" + + - name: activity + adapter: http + url: "{{ api_base }}/activity" + items: "$.data[*]" + optional: true + + merge: true + view: + type: table + columns: + - path: "$.name" + display: "User" + width: 30 + source: users + style: + - default: true + color: cyan + + - path: "$.email" + display: "Email" + width: 35 + source: users + + - path: "$.last_login" + display: "Last Login" + width: 20 + source: activity + transform: "{{ value | timeago }}" + style: + - default: true + color: yellow +``` + +## Generation Guidelines + +When generating a TermStack YAML: + +1. **Understand the data source** - API endpoints, CLI commands, or scripts +2. **Define globals** - API base URL and common variables +3. **Create the start page** - Usually a list/table view +4. **Add detail pages** - For viewing individual items +5. **Set up navigation** - Use `next` for Enter key, `actions` for shortcuts +6. **Add styling** - Color code important fields +7. **Use correct context** - Pass IDs/names via context, access directly by key name +8. **Choose the right adapter** - HTTP for APIs, CLI for commands, Script for custom processing, Stream for real-time data +9. **Select appropriate view** - Table for lists, Text for details, Logs for streaming +10. **Add transforms** - Use Tera filters for formatting (timeago, filesizeformat) +11. **Implement validation** - Add rules for data integrity +12. **Use conditional navigation** - Route to different pages based on data type + +## Common Patterns + +### REST API Browser +``` +List Page (table) → Detail Page (table/text) → Related Items (table) +``` + +### File Browser +``` +Directory (table) → Subdirectory (table) → File Content (text) +Use conditional navigation for files vs directories +``` + +### Kubernetes Dashboard +``` +Namespaces → Pods → Pod Details → Logs (streaming) +``` + +### Log Viewer +``` +Services (table) → Logs (logs view with filters) +Use stream adapter with follow mode +``` + +### Multi-Source Dashboard +``` +Users (merged from users + stats) → User Detail → User Activity +``` + +## Timeout Formats + +All timeout fields support these formats: +- Seconds: `"30s"`, `"5s"` +- Minutes: `"5m"`, `"30m"` +- Hours: `"1h"`, `"2h"` +- Combined: `"1h30m"`, `"2m30s"` + +## Running TermStack + +```bash +cargo run -- examples/your-config.yaml +``` + +## Navigation Keys + +- `Enter` - Navigate to next page (defined by `next`) +- `Esc` - Go back +- `a` + key - Trigger action +- `j`/`k` or arrows - Move up/down +- `g` - Go to top +- `G` - Go to bottom +- `/` - Search +- `q` - Quit +- `r` - Refresh data +- `f` - Toggle filter (in logs view) + +## Tips for Best Results + +1. **Always specify `items` JSONPath** - This tells TermStack where to find the array +2. **Use descriptive context variable names** - Makes templates easier to understand +3. **Add timeouts** - Prevent hanging on slow APIs or commands +4. **Use refresh_interval for dashboards** - Keep data current +5. **Add confirmation for destructive actions** - Use `confirm` field +6. **Style based on data values** - Use conditional styles for status, severity, etc. +7. **Use transforms for readability** - Format timestamps, file sizes, etc. +8. **Test JSONPath expressions** - Use online tools to verify paths +9. **Add optional flag to non-critical data sources** - Prevents failures +10. **Use builtin actions** - Leverage built-in functionality (help, search, refresh) diff --git a/.claude/skills/termstack-yaml-generator/examples/dog-api.yaml b/.claude/skills/termstack-yaml-generator/examples/dog-api.yaml new file mode 100644 index 0000000..6dfe45b --- /dev/null +++ b/.claude/skills/termstack-yaml-generator/examples/dog-api.yaml @@ -0,0 +1,278 @@ +# Dog API Example +# Browse dog breeds and facts from DogAPI.dog +# +# Usage: cargo run -- examples/dog-api.yaml +# +# This example demonstrates: +# - HTTP adapter for REST API calls +# - JSON:API format handling +# - Multi-page navigation with context passing +# - Detailed breed information +# - No authentication required + +version: v1 + +app: + name: "Dog Breeds Browser" + description: "Explore dog breeds and facts" + theme: "default" + +globals: + api_base: "https://dogapi.dog/api/v2" + +start: breeds + +pages: + breeds: + title: "Dog Breeds" + data: + adapter: http + url: "{{ api_base }}/breeds" + method: GET + headers: + Accept: "application/json" + items: "$.data[*]" + refresh_interval: "10m" + view: + type: table + columns: + - path: "$.attributes.name" + display: "Breed" + width: 30 + style: + - default: true + color: cyan + bold: true + - path: "$.attributes.life.min" + display: "Min Life" + width: 10 + style: + - default: true + color: green + - path: "$.attributes.life.max" + display: "Max Life" + width: 10 + style: + - default: true + color: green + - path: "$.attributes.male_weight.min" + display: "Male Wt (kg)" + width: 12 + style: + - default: true + color: blue + - path: "$.attributes.female_weight.min" + display: "Female Wt (kg)" + width: 14 + style: + - default: true + color: magenta + - path: "$.attributes.hypoallergenic" + display: "Hypo" + width: 6 + style: + - condition: "{{ value == 'true' }}" + color: yellow + bold: true + - default: true + color: gray + sort: + column: "$.attributes.name" + order: asc + next: + page: breed_detail + context: + breed_id: "$.id" + breed_name: "$.attributes.name" + actions: + - key: "f" + name: "View Facts" + description: "View random dog facts" + page: "facts" + - key: "g" + name: "View Groups" + description: "View breed groups" + page: "groups" + + breed_detail: + title: "{{ breed_name }}" + data: + adapter: http + url: "{{ api_base }}/breeds/{{ breed_id }}" + method: GET + headers: + Accept: "application/json" + items: "$.data" + view: + type: table + columns: + - path: "$.attributes.name" + display: "Name" + width: 30 + style: + - default: true + color: cyan + bold: true + - path: "$.attributes.description" + display: "Description" + width: 80 + - path: "$.attributes.life.min" + display: "Min Life (yrs)" + width: 15 + style: + - default: true + color: green + - path: "$.attributes.life.max" + display: "Max Life (yrs)" + width: 15 + style: + - default: true + color: green + - path: "$.attributes.male_weight.min" + display: "Male Min (kg)" + width: 14 + style: + - default: true + color: blue + - path: "$.attributes.male_weight.max" + display: "Male Max (kg)" + width: 14 + style: + - default: true + color: blue + - path: "$.attributes.female_weight.min" + display: "Female Min (kg)" + width: 16 + style: + - default: true + color: magenta + - path: "$.attributes.female_weight.max" + display: "Female Max (kg)" + width: 16 + style: + - default: true + color: magenta + - path: "$.attributes.hypoallergenic" + display: "Hypoallergenic" + width: 15 + style: + - condition: "{{ value == 'true' }}" + color: yellow + bold: true + - default: true + color: gray + actions: + - key: "f" + name: "View Facts" + description: "View random dog facts" + page: "facts" + - key: "y" + name: "View as YAML" + description: "View full JSON response" + page: "breed_yaml" + + breed_yaml: + title: "{{ breed_name }} (YAML)" + data: + adapter: http + url: "{{ api_base }}/breeds/{{ breed_id }}" + method: GET + headers: + Accept: "application/json" + view: + type: text + syntax: yaml + + facts: + title: "Dog Facts" + data: + adapter: http + url: "{{ api_base }}/facts" + method: GET + headers: + Accept: "application/json" + items: "$.data[*]" + view: + type: table + columns: + - path: "$.attributes.body" + display: "Fact" + width: 100 + style: + - default: true + color: yellow + next: + page: fact_detail + context: + fact_id: "$.id" + fact_body: "$.attributes.body" + + fact_detail: + title: "Dog Fact" + data: + adapter: http + url: "{{ api_base }}/facts/{{ fact_id }}" + method: GET + headers: + Accept: "application/json" + view: + type: text + syntax: yaml + + groups: + title: "Breed Groups" + data: + adapter: http + url: "{{ api_base }}/groups" + method: GET + headers: + Accept: "application/json" + items: "$.data[*]" + view: + type: table + columns: + - path: "$.attributes.name" + display: "Group Name" + width: 50 + style: + - default: true + color: cyan + bold: true + next: + page: group_detail + context: + group_id: "$.id" + group_name: "$.attributes.name" + + group_detail: + title: "{{ group_name }}" + data: + adapter: http + url: "{{ api_base }}/groups/{{ group_id }}" + method: GET + headers: + Accept: "application/json" + items: "$.data" + view: + type: table + columns: + - path: "$.id" + display: "Group ID" + width: 40 + style: + - default: true + color: blue + - path: "$.attributes.name" + display: "Group Name" + width: 40 + style: + - default: true + color: cyan + bold: true + - path: "$.relationships.breeds.data" + display: "Breeds Count" + width: 15 + transform: "{{ value | length }}" + style: + - default: true + color: green diff --git a/.claude/skills/termstack-yaml-generator/examples/kubernetes-cli.yaml b/.claude/skills/termstack-yaml-generator/examples/kubernetes-cli.yaml new file mode 100644 index 0000000..d20c9fc --- /dev/null +++ b/.claude/skills/termstack-yaml-generator/examples/kubernetes-cli.yaml @@ -0,0 +1,868 @@ +version: v1 + +app: + name: "Kubernetes TUI" + description: "Navigate Kubernetes resources with k9s-style interface" + theme: "default" + +# Start at namespaces view +start: namespaces + +pages: + # ============================================================================ + # NAMESPACES - List all namespaces + # ============================================================================ + namespaces: + title: "Namespaces" + description: "List all Kubernetes namespaces" + data: + adapter: cli + command: "kubectl" + args: ["get", "namespaces", "-o", "json"] + items: "$.items[*]" + timeout: "30s" + refresh_interval: "60s" + view: + type: table + columns: + - path: "$.metadata.name" + display: "Name" + width: 40 + style: + - default: true + color: cyan + bold: true + - path: "$.status.phase" + display: "Status" + width: 15 + style: + - condition: "{{ value == 'Active' }}" + color: green + bold: true + - condition: "{{ value == 'Terminating' }}" + color: yellow + - default: true + color: red + - path: "$.metadata.creationTimestamp" + display: "Age" + width: 20 + transform: "{{ value | timeago }}" + style: + - default: true + color: gray + sort: + column: "$.metadata.name" + order: asc + row_style: + - condition: "{{ status.phase == 'Terminating' }}" + dim: true + next: + page: pods + context: + namespace: "$.metadata.name" + actions: + - key: "p" + name: "Pods" + description: "View pods in namespace" + page: pods + context: + namespace: "$.metadata.name" + - key: "d" + name: "Deployments" + description: "View deployments in namespace" + page: deployments + context: + namespace: "$.metadata.name" + - key: "s" + name: "Services" + description: "View services in namespace" + page: services + context: + namespace: "$.metadata.name" + - key: "r" + name: "RBAC" + description: "View RBAC resources" + page: rbac_overview + context: + namespace: "$.metadata.name" + + # ============================================================================ + # PODS - List pods in selected namespace + # ============================================================================ + pods: + title: "Pods - {{ namespaces.metadata.name }}" + description: "List all pods in namespace" + data: + adapter: cli + command: "kubectl" + args: + ["get", "pods", "-n", "{{ namespaces.metadata.name }}", "-o", "json"] + items: "$.items[*]" + timeout: "30s" + refresh_interval: "5s" + view: + type: table + columns: + - path: "$.metadata.name" + display: "Name" + width: 45 + style: + - default: true + color: cyan + - path: "$.status.phase" + display: "Status" + width: 12 + style: + - condition: "{{ value == 'Running' }}" + color: green + bold: true + - condition: "{{ value == 'Pending' }}" + color: yellow + - condition: "{{ value == 'Succeeded' }}" + color: blue + - condition: "{{ value == 'Failed' }}" + color: red + bold: true + - condition: "{{ value == 'Unknown' }}" + color: magenta + - default: true + color: white + - path: "$.status.conditions[?(@.type=='Ready')].status" + display: "Ready" + width: 8 + style: + - condition: "{{ value == 'True' }}" + color: green + - condition: "{{ value == 'False' }}" + color: red + - default: true + color: yellow + - path: "$.spec.containers[*].name" + display: "Containers" + transform: "{{ value | length }}" + width: 12 + style: + - default: true + color: blue + - path: "$.status.containerStatuses[?(@.ready==true)]" + display: "Ready/Total" + transform: "{{ value | length }}/{{ row.status.containerStatuses | length }}" + width: 12 + style: + - condition: "{{ value | length == row.status.containerStatuses | length }}" + color: green + - default: true + color: yellow + - path: "$.metadata.creationTimestamp" + display: "Age" + transform: "{{ value | timeago }}" + width: 15 + style: + - default: true + color: gray + row_style: + - condition: "{{ status.phase == 'Failed' }}" + color: red + dim: true + - condition: "{{ status.phase == 'Pending' }}" + dim: true + - condition: "{{ status.phase == 'Running' }}" + default: true + next: + page: pod_detail + context: + pod_name: "$.metadata.name" + namespace: "{{ namespaces.metadata.name }}" + actions: + - key: "l" + name: "Logs" + description: "View pod logs" + page: pod_logs + context: + pod_name: "$.metadata.name" + namespace: "{{ namespaces.metadata.name }}" + - key: "d" + name: "Delete" + description: "Delete pod" + confirm: "Delete pod {{ metadata.name }}?" + command: "kubectl" + args: + [ + "delete", + "pod", + "{{ metadata.name }}", + "-n", + "{{ namespaces.metadata.name }}", + ] + notification: + on_success: "Pod '{{ metadata.name }}' deleted successfully" + on_failure: "Failed to delete pod '{{ metadata.name }}'" + refresh: true + - key: "e" + name: "Describe" + description: "Describe pod" + page: pod_describe + + # ============================================================================ + # POD DETAIL - Show detailed information about a pod + # ============================================================================ + pod_detail: + title: "Pod - {{ pods.metadata.name }}" + description: "Detailed pod information" + data: + adapter: cli + command: "kubectl" + args: + [ + "get", + "pod", + "{{ pods.metadata.name }}", + "-n", + "{{ namespaces.metadata.name }}", + "-o", + "yaml", + ] + items: "@this" + timeout: "30s" + view: + type: text + syntax: yaml + line_numbers: true + actions: + - key: "l" + name: "Logs" + description: "View logs" + page: pod_logs + - key: "y" + name: "YAML" + description: "View raw YAML" + builtin: yaml_view + + # ============================================================================ + # POD LOGS - Stream pod logs + # ============================================================================ + pod_logs: + title: "Logs - {{ pods.metadata.name }}" + description: "Pod logs (streaming)" + data: + type: stream + command: "kubectl" + args: + [ + "logs", + "-f", + "{{ pods.metadata.name }}", + "-n", + "{{ namespaces.metadata.name }}", + "--tail=100", + ] + buffer_size: 100 + buffer_time: "15m" + follow: true + view: + type: logs + follow: true + wrap: true + show_timestamps: false + + # ============================================================================ + # POD DESCRIBE - kubectl describe output + # ============================================================================ + pod_describe: + title: "Describe - {{ pods.metadata.name }}" + description: "kubectl describe output" + data: + adapter: cli + command: "kubectl" + args: + [ + "describe", + "pod", + "{{ pods.metadata.name }}", + "-n", + "{{ namespaces.metadata.name }}", + ] + items: "@this" + timeout: "30s" + view: + type: text + line_numbers: true + + # ============================================================================ + # DEPLOYMENTS - List deployments in namespace + # ============================================================================ + deployments: + title: "Deployments - {{ namespaces.metadata.name }}" + description: "List all deployments" + data: + adapter: cli + command: "kubectl" + args: + [ + "get", + "deployments", + "-n", + "{{ namespaces.metadata.name }}", + "-o", + "json", + ] + items: "$.items[*]" + timeout: "30s" + refresh_interval: "10s" + view: + type: table + columns: + - path: "$.metadata.name" + display: "Name" + width: 35 + style: + - default: true + color: cyan + bold: true + - path: "$.spec.replicas" + display: "Desired" + width: 10 + style: + - default: true + color: blue + - path: "$.status.availableReplicas" + display: "Available" + width: 12 + style: + - condition: "{{ value == row.spec.replicas }}" + color: green + bold: true + - condition: "{{ value > 0 }}" + color: yellow + - default: true + color: red + - path: "$.status.readyReplicas" + display: "Ready" + width: 10 + style: + - condition: "{{ value == row.spec.replicas }}" + color: green + - default: true + color: yellow + - path: "$.status.updatedReplicas" + display: "Updated" + width: 10 + style: + - condition: "{{ value == row.spec.replicas }}" + color: green + - default: true + color: cyan + - path: "$.metadata.creationTimestamp" + display: "Age" + transform: "{{ value | timeago }}" + width: 15 + style: + - default: true + color: gray + row_style: + - condition: "{{ status.availableReplicas != spec.replicas }}" + color: yellow + next: + page: deployment_detail + context: + deployment_name: "$.metadata.name" + actions: + - key: "s" + name: "Scale" + description: "Scale deployment" + command: "kubectl" + args: + [ + "scale", + "deployment", + "{{ metadata.name }}", + "-n", + "{{ namespaces.metadata.name }}", + "--replicas=3", + ] + confirm: "Scale {{ metadata.name }} to 3 replicas?" + success_message: "Scaled {{ metadata.name }}" + refresh: true + - key: "r" + name: "Restart" + description: "Restart deployment" + command: "kubectl" + args: + [ + "rollout", + "restart", + "deployment", + "{{ metadata.name }}", + "-n", + "{{ namespaces.metadata.name }}", + ] + confirm: "Restart {{ metadata.name }}?" + success_message: "Restarted {{ metadata.name }}" + refresh: true + - key: "d" + name: "Delete" + description: "Delete deployment" + confirm: "Delete deployment {{ metadata.name }}?" + command: "kubectl" + args: + [ + "delete", + "deployment", + "{{ metadata.name }}", + "-n", + "{{ namespaces.metadata.name }}", + ] + success_message: "Deleted {{ metadata.name }}" + error_message: "Failed to delete" + refresh: true + + # ============================================================================ + # DEPLOYMENT DETAIL - Show deployment details + # ============================================================================ + deployment_detail: + title: "Deployment - {{ deployments.metadata.name }}" + description: "Deployment details" + data: + adapter: cli + command: "kubectl" + args: + [ + "get", + "deployment", + "{{ deployments.metadata.name }}", + "-n", + "{{ namespaces.metadata.name }}", + "-o", + "json", + ] + items: "@this" + timeout: "30s" + view: + type: text + syntax: json + line_numbers: true + actions: + - key: "p" + name: "Pods" + description: "View pods for this deployment" + page: deployment_pods + + # ============================================================================ + # DEPLOYMENT PODS - Show pods for a deployment + # ============================================================================ + deployment_pods: + title: "Pods - {{ deployments.metadata.name }}" + description: "Pods managed by deployment" + data: + adapter: cli + command: "kubectl" + args: + [ + "get", + "pods", + "-n", + "{{ namespaces.metadata.name }}", + "-l", + "app={{ deployments.metadata.labels.app }}", + "-o", + "json", + ] + items: "$.items[*]" + timeout: "30s" + view: + type: table + columns: + - path: "$.metadata.name" + display: "Name" + width: 50 + style: + - default: true + color: cyan + - path: "$.status.phase" + display: "Status" + width: 12 + style: + - condition: "{{ value == 'Running' }}" + color: green + bold: true + - condition: "{{ value == 'Pending' }}" + color: yellow + - default: true + color: red + - path: "$.metadata.creationTimestamp" + display: "Age" + transform: "{{ value | timeago }}" + width: 15 + style: + - default: true + color: gray + next: + page: pod_detail + context: + pod_name: "$.metadata.name" + + # ============================================================================ + # SERVICES - List services in namespace + # ============================================================================ + services: + title: "Services - {{ namespaces.metadata.name }}" + description: "List all services" + data: + adapter: cli + command: "kubectl" + args: + [ + "get", + "services", + "-n", + "{{ namespaces.metadata.name }}", + "-o", + "json", + ] + items: "$.items[*]" + timeout: "30s" + view: + type: table + columns: + - path: "$.metadata.name" + display: "Name" + width: 30 + style: + - default: true + color: cyan + bold: true + - path: "$.spec.type" + display: "Type" + width: 15 + style: + - condition: "{{ value == 'LoadBalancer' }}" + color: magenta + bold: true + - condition: "{{ value == 'NodePort' }}" + color: yellow + - condition: "{{ value == 'ClusterIP' }}" + color: blue + - default: true + color: white + - path: "$.spec.clusterIP" + display: "Cluster IP" + width: 18 + style: + - default: true + color: green + - path: "$.spec.ports[*].port" + display: "Ports" + width: 20 + style: + - default: true + color: blue + - path: "$.metadata.creationTimestamp" + display: "Age" + transform: "{{ value | timeago }}" + width: 15 + style: + - default: true + color: gray + next: + page: service_detail + context: + service_name: "$.metadata.name" + actions: + - key: "d" + name: "Delete" + description: "Delete service" + confirm: "Delete service {{ metadata.name }}?" + command: "kubectl" + args: + [ + "delete", + "service", + "{{ metadata.name }}", + "-n", + "{{ namespaces.metadata.name }}", + ] + success_message: "Deleted service {{ metadata.name }}" + refresh: true + + # ============================================================================ + # SERVICE DETAIL - Show service details + # ============================================================================ + service_detail: + title: "Service - {{ services.metadata.name }}" + description: "Service details" + data: + adapter: cli + command: "kubectl" + args: + [ + "get", + "service", + "{{ services.metadata.name }}", + "-n", + "{{ namespaces.metadata.name }}", + "-o", + "yaml", + ] + items: "@this" + timeout: "30s" + view: + type: text + syntax: yaml + line_numbers: true + + # ============================================================================ + # RBAC OVERVIEW - Show RBAC resources + # ============================================================================ + rbac_overview: + title: "RBAC - {{ namespaces.metadata.name }}" + description: "Role-Based Access Control resources" + data: + adapter: cli + command: "kubectl" + args: + [ + "get", + "roles,rolebindings", + "-n", + "{{ namespaces.metadata.name }}", + "-o", + "json", + ] + items: "$.items[*]" + timeout: "30s" + view: + type: table + columns: + - path: "$.kind" + display: "Kind" + width: 15 + style: + - condition: "{{ value == 'Role' }}" + color: yellow + bold: true + - condition: "{{ value == 'RoleBinding' }}" + color: magenta + bold: true + - default: true + color: cyan + - path: "$.metadata.name" + display: "Name" + width: 40 + style: + - default: true + color: cyan + - path: "$.metadata.creationTimestamp" + display: "Age" + transform: "{{ value | timeago }}" + width: 20 + style: + - default: true + color: gray + next: + page: rbac_detail + context: + rbac_kind: "$.kind" + rbac_name: "$.metadata.name" + actions: + - key: "r" + name: "Roles" + description: "View roles only" + page: roles + context: + namespace: "{{ namespaces.metadata.name }}" + - key: "b" + name: "RoleBindings" + description: "View role bindings only" + page: rolebindings + context: + namespace: "{{ namespaces.metadata.name }}" + - key: "c" + name: "ClusterRoles" + description: "View cluster-wide roles" + page: clusterroles + + # ============================================================================ + # ROLES - List roles in namespace + # ============================================================================ + roles: + title: "Roles - {{ namespaces.metadata.name }}" + description: "Namespace-scoped roles" + data: + adapter: cli + command: "kubectl" + args: + ["get", "roles", "-n", "{{ namespaces.metadata.name }}", "-o", "json"] + items: "$.items[*]" + timeout: "30s" + view: + type: table + columns: + - path: "$.metadata.name" + display: "Name" + width: 50 + style: + - default: true + color: yellow + bold: true + - path: "$.rules[*]" + display: "Rules" + transform: "{{ value | length }}" + width: 10 + style: + - default: true + color: blue + - path: "$.metadata.creationTimestamp" + display: "Age" + transform: "{{ value | timeago }}" + width: 20 + style: + - default: true + color: gray + next: + page: rbac_detail + context: + rbac_kind: "Role" + rbac_name: "$.metadata.name" + + # ============================================================================ + # ROLEBINDINGS - List role bindings in namespace + # ============================================================================ + rolebindings: + title: "RoleBindings - {{ namespaces.metadata.name }}" + description: "Namespace-scoped role bindings" + data: + adapter: cli + command: "kubectl" + args: + [ + "get", + "rolebindings", + "-n", + "{{ namespaces.metadata.name }}", + "-o", + "json", + ] + items: "$.items[*]" + timeout: "30s" + view: + type: table + columns: + - path: "$.metadata.name" + display: "Name" + width: 40 + style: + - default: true + color: magenta + bold: true + - path: "$.roleRef.name" + display: "Role" + width: 30 + style: + - default: true + color: yellow + - path: "$.subjects[*]" + display: "Subjects" + transform: "{{ value | length }}" + width: 10 + style: + - default: true + color: blue + - path: "$.metadata.creationTimestamp" + display: "Age" + transform: "{{ value | timeago }}" + width: 20 + style: + - default: true + color: gray + next: + page: rbac_detail + context: + rbac_kind: "RoleBinding" + rbac_name: "$.metadata.name" + + # ============================================================================ + # CLUSTERROLES - List cluster roles + # ============================================================================ + clusterroles: + title: "ClusterRoles" + description: "Cluster-wide roles" + data: + adapter: cli + command: "kubectl" + args: ["get", "clusterroles", "-o", "json"] + items: "$.items[*]" + timeout: "30s" + view: + type: table + columns: + - path: "$.metadata.name" + display: "Name" + width: 50 + style: + - condition: "{{ value | startswith('system:') }}" + color: gray + - default: true + color: yellow + bold: true + - path: "$.rules[*]" + display: "Rules" + transform: "{{ value | length }}" + width: 10 + style: + - default: true + color: blue + - path: "$.metadata.creationTimestamp" + display: "Age" + transform: "{{ value | timeago }}" + width: 20 + style: + - default: true + color: gray + next: + page: clusterrole_detail + context: + clusterrole_name: "$.metadata.name" + + # ============================================================================ + # RBAC DETAIL - Show detailed RBAC resource information + # ============================================================================ + rbac_detail: + title: "{{ rbac_overview.kind }} - {{ rbac_overview.metadata.name }}" + description: "RBAC resource details" + data: + adapter: cli + command: "kubectl" + args: + [ + "get", + "{{ rbac_overview.kind }}", + "{{ rbac_overview.metadata.name }}", + "-n", + "{{ namespaces.metadata.name }}", + "-o", + "yaml", + ] + items: "@this" + timeout: "30s" + view: + type: text + syntax: yaml + line_numbers: true + + # ============================================================================ + # CLUSTERROLE DETAIL - Show cluster role details + # ============================================================================ + clusterrole_detail: + title: "ClusterRole - {{ clusterroles.metadata.name }}" + description: "Cluster role details" + data: + adapter: cli + command: "kubectl" + args: + ["get", "clusterrole", "{{ clusterroles.metadata.name }}", "-o", "yaml"] + items: "@this" + timeout: "30s" + view: + type: text + syntax: yaml + line_numbers: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 653741c..4cb8d12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,90 +1,133 @@ +# ============================================================================= +# Continuous Integration +# ============================================================================= +# Runs on every push and pull request to ensure code quality. +# Tests across multiple platforms because we're not savages. +# ============================================================================= + name: CI on: - pull_request: push: - branches: - - main - - master - - develop + tags: + - "v[0-9]+.[0-9]+.[0-9]+" # v1.0.0 + - "v[0-9]+.[0-9]+.[0-9]+-*" # v1.0.0-beta.1, v1.0.0-rc.1 env: CARGO_TERM_COLOR: always - -# ensure that the workflow is only triggered once per PR, subsequent pushes to the PR will cancel -# and restart the workflow. See https://docs.github.com/en/actions/using-jobs/using-concurrency -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true + RUST_BACKTRACE: 1 jobs: - fmt: - name: fmt + # --------------------------------------------------------------------------- + # Code Quality Checks + # --------------------------------------------------------------------------- + lint: + name: Lint & Format runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - - name: Install Rust stable + + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: - components: rustfmt - - name: check formatting - run: cargo fmt -- --check - - name: Cache Cargo dependencies + components: rustfmt, clippy + + - name: Cache cargo uses: Swatinem/rust-cache@v2 - clippy: - name: clippy - runs-on: ubuntu-latest - permissions: - contents: read - checks: write + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + # --------------------------------------------------------------------------- + # Tests + # --------------------------------------------------------------------------- + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] steps: - name: Checkout uses: actions/checkout@v4 - - name: Install Rust stable + + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - name: Run clippy action - uses: clechasseur/rs-clippy-check@v3 - - name: Cache Cargo dependencies + + - name: Cache cargo uses: Swatinem/rust-cache@v2 - doc: - # run docs generation on nightly rather than stable. This enables features like - # https://doc.rust-lang.org/beta/unstable-book/language-features/doc-cfg.html which allows an - # API be documented as only available in some specific platforms. - name: doc - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Install Rust nightly - uses: dtolnay/rust-toolchain@nightly - - name: Run cargo doc - run: cargo doc --no-deps --all-features - env: - RUSTDOCFLAGS: --cfg docsrs - test: + + - name: Run tests + run: cargo test --all-features + + # --------------------------------------------------------------------------- + # Build Verification + # --------------------------------------------------------------------------- + build: + name: Build (${{ matrix.target }}) runs-on: ${{ matrix.os }} - name: test ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [macos-latest, windows-latest] + include: + # macOS Intel + - os: macos-latest + target: x86_64-apple-darwin + name: macos-amd64 + # macOS Apple Silicon + - os: macos-latest + target: aarch64-apple-darwin + name: macos-arm64 + # Linux x86_64 + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + name: linux-amd64 + # Linux ARM64 + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + name: linux-arm64 steps: - # if your project needs OpenSSL, uncomment this to fix Windows builds. - # it's commented out by default as the install command takes 5-10m. - # - run: echo "VCPKG_ROOT=$env:VCPKG_INSTALLATION_ROOT" | Out-File -FilePath $env:GITHUB_ENV -Append - # if: runner.os == 'Windows' - # - run: vcpkg install openssl:x64-windows-static-md - # if: runner.os == 'Windows' - - uses: actions/checkout@v4 - - name: Install Rust + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable - # enable this ci template to run regardless of whether the lockfile is checked in or not - - name: cargo generate-lockfile - if: hashFiles('Cargo.lock') == '' - run: cargo generate-lockfile - - name: cargo test --locked - run: cargo test --locked --all-features --all-targets - - name: Cache Cargo dependencies + with: + targets: ${{ matrix.target }} + + - name: Install cross-compilation tools (Linux ARM) + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + + - name: Cache cargo uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + + - name: Build + run: cargo build --release --target ${{ matrix.target }} + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + + # --------------------------------------------------------------------------- + # Security Audit + # --------------------------------------------------------------------------- + security: + name: Security Audit + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install cargo-audit + run: cargo install cargo-audit + + - name: Run security audit + run: cargo audit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4222374 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,187 @@ +# ============================================================================= +# Release Workflow +# ============================================================================= +# Automatically builds and releases binaries when a version tag is pushed. +# Uses semantic versioning (v1.0.0, v1.2.3, etc.) +# +# To release: +# git tag v1.0.0 +# git push origin v1.0.0 +# +# This will: +# 1. Build binaries for all platforms +# 2. Create a GitHub release +# 3. Upload binaries as release assets +# 4. Generate release notes from commits +# ============================================================================= + +name: Release + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" # v1.0.0 + - "v[0-9]+.[0-9]+.[0-9]+-*" # v1.0.0-beta.1, v1.0.0-rc.1 + +env: + CARGO_TERM_COLOR: always + BINARY_NAME: termstack + +jobs: + # --------------------------------------------------------------------------- + # Create Release + # --------------------------------------------------------------------------- + create-release: + name: Create Release + runs-on: ubuntu-latest + outputs: + upload_url: ${{ steps.create_release.outputs.upload_url }} + version: ${{ steps.get_version.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get version from tag + id: get_version + run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + + - name: Generate changelog + id: changelog + run: | + # Get commits since last tag + PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + if [ -n "$PREV_TAG" ]; then + CHANGELOG=$(git log --pretty=format:"- %s (%h)" $PREV_TAG..HEAD) + else + CHANGELOG=$(git log --pretty=format:"- %s (%h)" HEAD~10..HEAD) + fi + echo "changelog<> $GITHUB_OUTPUT + echo "$CHANGELOG" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create GitHub Release + id: create_release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ steps.get_version.outputs.version }} + name: TermStack ${{ steps.get_version.outputs.version }} + body: | + ## What's New + + ${{ steps.changelog.outputs.changelog }} + + ## Installation + + Download the appropriate binary for your platform below, then: + + ```bash + # macOS/Linux: Extract and install + tar -xzf termstack-.tar.gz + chmod +x termstack + sudo mv termstack /usr/local/bin/ + + # Verify installation + termstack --help + ``` + + ### Available Binaries + + | Platform | Architecture | File | + |----------|--------------|------| + | macOS | Intel (x86_64) | `termstack-macos-amd64.tar.gz` | + | macOS | Apple Silicon (ARM64) | `termstack-macos-arm64.tar.gz` | + | Linux | x86_64 | `termstack-linux-amd64.tar.gz` | + | Linux | ARM64 | `termstack-linux-arm64.tar.gz` | + + ## Checksums + + SHA256 checksums are provided for each binary. Verify with: + ```bash + sha256sum -c termstack-.sha256 + ``` + draft: false + prerelease: ${{ contains(steps.get_version.outputs.version, '-') }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # --------------------------------------------------------------------------- + # Build Binaries + # --------------------------------------------------------------------------- + build-binaries: + name: Build (${{ matrix.name }}) + needs: create-release + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + # macOS Intel + - os: macos-latest + target: x86_64-apple-darwin + name: macos-amd64 + artifact: termstack-macos-amd64 + # macOS Apple Silicon + - os: macos-latest + target: aarch64-apple-darwin + name: macos-arm64 + artifact: termstack-macos-arm64 + # Linux x86_64 + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + name: linux-amd64 + artifact: termstack-linux-amd64 + # Linux ARM64 + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + name: linux-arm64 + artifact: termstack-linux-arm64 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install cross-compilation tools (Linux ARM) + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + key: release-${{ matrix.target }} + + - name: Build release binary + run: cargo build --release --target ${{ matrix.target }} + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + + - name: Package binary + run: | + cd target/${{ matrix.target }}/release + tar czvf ../../../${{ matrix.artifact }}.tar.gz ${{ env.BINARY_NAME }} + cd ../../.. + + - name: Generate checksum + run: | + if [ "${{ matrix.os }}" = "macos-latest" ]; then + shasum -a 256 ${{ matrix.artifact }}.tar.gz > ${{ matrix.artifact }}.sha256 + else + sha256sum ${{ matrix.artifact }}.tar.gz > ${{ matrix.artifact }}.sha256 + fi + + - name: Upload binary to release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ needs.create-release.outputs.version }} + files: | + ${{ matrix.artifact }}.tar.gz + ${{ matrix.artifact }}.sha256 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index a7f3b5f..6399ab2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,12 +17,122 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "ansi-to-tui" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67555e1f1ece39d737e28c8a017721287753af3f93225e4a445b29ccb0f5912c" +dependencies = [ + "nom", + "ratatui 0.29.0", + "simdutf8", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "backtrace" version = "0.3.76" @@ -38,12 +148,58 @@ dependencies = [ "windows-link", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + [[package]] name = "cassowary" version = "0.3.0" @@ -59,12 +215,97 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cc" +version = "1.2.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", +] + +[[package]] +name = "clap" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + [[package]] name = "color-eyre" version = "0.6.5" @@ -92,6 +333,12 @@ dependencies = [ "tracing-error", ] +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "compact_str" version = "0.8.1" @@ -106,6 +353,65 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crossterm" version = "0.28.1" @@ -116,7 +422,7 @@ dependencies = [ "crossterm_winapi", "mio", "parking_lot", - "rustix", + "rustix 0.38.44", "signal-hook", "signal-hook-mio", "winapi", @@ -131,6 +437,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.20.11" @@ -166,12 +482,48 @@ dependencies = [ "syn", ] +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -198,6 +550,28 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -211,400 +585,1823 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "gimli" -version = "0.32.3" +name = "foreign-types" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] [[package]] -name = "hashbrown" -version = "0.15.5" +name = "foreign-types-shared" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", + "percent-encoding", ] [[package]] -name = "heck" -version = "0.5.0" +name = "futures-channel" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] [[package]] -name = "ident_case" -version = "1.0.1" +name = "futures-core" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] -name = "indenter" -version = "0.3.4" +name = "futures-sink" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] -name = "indoc" -version = "2.0.7" +name = "futures-task" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ - "rustversion", + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", ] [[package]] -name = "instability" -version = "0.3.10" +name = "generic-array" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ - "darling", - "indoc", - "proc-macro2", - "quote", - "syn", + "typenum", + "version_check", ] [[package]] -name = "itertools" -version = "0.13.0" +name = "getrandom" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ - "either", + "cfg-if", + "libc", + "wasi", ] [[package]] -name = "itoa" -version = "1.0.15" +name = "getrandom" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] [[package]] -name = "lazy_static" -version = "1.5.0" +name = "gimli" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] -name = "libc" -version = "0.2.177" +name = "globset" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] [[package]] -name = "linux-raw-sys" -version = "0.4.15" +name = "globwalk" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags", + "ignore", + "walkdir", +] [[package]] -name = "lock_api" -version = "0.4.14" +name = "h2" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" dependencies = [ - "scopeguard", + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] -name = "log" -version = "0.4.28" +name = "hashbrown" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] -name = "lru" -version = "0.12.5" +name = "hashbrown" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown", -] +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] -name = "memchr" -version = "2.7.6" +name = "heck" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "miniz_oxide" -version = "0.8.9" +name = "http" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ - "adler2", + "bytes", + "itoa", ] [[package]] -name = "mio" -version = "1.1.0" +name = "http-body" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.61.2", + "bytes", + "http", ] [[package]] -name = "object" -version = "0.37.3" +name = "http-body-util" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ - "memchr", + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", ] [[package]] -name = "once_cell" -version = "1.21.3" +name = "httparse" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] -name = "owo-colors" -version = "4.2.3" +name = "humansize" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] [[package]] -name = "parking_lot" -version = "0.12.5" +name = "humantime" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "hyper" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ - "cfg-if", - "libc", - "redox_syscall", + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", "smallvec", - "windows-link", + "tokio", + "want", ] [[package]] -name = "paste" -version = "1.0.15" +name = "hyper-rustls" +version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] [[package]] -name = "pin-project-lite" -version = "0.2.16" +name = "hyper-tls" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] [[package]] -name = "proc-macro2" -version = "1.0.103" +name = "hyper-util" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" dependencies = [ - "unicode-ident", + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", ] [[package]] -name = "quote" -version = "1.0.42" +name = "iana-time-zone" +version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ - "proc-macro2", + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", ] [[package]] -name = "ratatui" -version = "0.29.0" +name = "iana-time-zone-haiku" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ - "bitflags", - "cassowary", - "compact_str", - "crossterm", - "indoc", - "instability", - "itertools", - "lru", - "paste", - "strum", - "unicode-segmentation", - "unicode-truncate", - "unicode-width 0.2.0", + "cc", ] [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "icu_collections" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ - "bitflags", + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", ] [[package]] -name = "rustc-demangle" -version = "0.1.26" +name = "icu_locale_core" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] [[package]] -name = "rustix" -version = "0.38.44" +name = "icu_normalizer" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.59.0", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "icu_normalizer_data" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] -name = "ryu" -version = "1.0.20" +name = "icu_properties" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "icu_properties_data" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" [[package]] -name = "sharded-slab" -version = "0.1.7" +name = "icu_provider" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ - "lazy_static", + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", ] [[package]] -name = "signal-hook" -version = "0.3.18" +name = "ident_case" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] -name = "signal-hook-mio" -version = "0.2.5" +name = "idna" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ - "libc", - "mio", - "signal-hook", + "idna_adapter", + "smallvec", + "utf8_iter", ] [[package]] -name = "signal-hook-registry" -version = "1.4.7" +name = "idna_adapter" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ - "libc", + "icu_normalizer", + "icu_properties", ] [[package]] -name = "smallvec" -version = "1.15.1" +name = "ignore" +version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] [[package]] -name = "static_assertions" -version = "1.1.0" +name = "indenter" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] -name = "strsim" -version = "0.11.1" +name = "indexmap" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] [[package]] -name = "strum" -version = "0.26.3" +name = "indoc" +version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" dependencies = [ - "strum_macros", + "rustversion", ] [[package]] -name = "strum_macros" -version = "0.26.4" +name = "instability" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" dependencies = [ - "heck", + "darling", + "indoc", "proc-macro2", "quote", - "rustversion", "syn", ] [[package]] -name = "syn" -version = "2.0.111" +name = "inventory" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "rustversion", ] [[package]] -name = "termstack" -version = "0.1.0" -dependencies = [ - "color-eyre", - "crossterm", - "ratatui", -] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] -name = "thread_local" -version = "1.1.9" +name = "iri-string" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" dependencies = [ - "cfg-if", + "memchr", + "serde", ] [[package]] -name = "tracing" -version = "0.1.43" +name = "is_terminal_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "onig" +version = "6.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" +dependencies = [ + "bitflags", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "owo-colors" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbcfd20a6d4eeba40179f05735784ad32bdaef05ce8e8af05f180d45bb3e7e22" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f72981ade67b1ca6adc26ec221be9f463f2b5839c7508998daa17c23d94d7f" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee9efd8cdb50d719a80088b76f81aec7c41ed6d522ee750178f83883d271625" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf1d70880e76bdc13ba52eafa6239ce793d85c8e43896507e43dd8984ff05b82" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "ratatui" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "instability", + "itertools", + "lru", + "paste", + "strum", + "strum_macros", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.1.14", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.12.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_json_path" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b992cea3194eea663ba99a042d61cea4bd1872da37021af56f6a37e0359b9d33" +dependencies = [ + "inventory", + "nom", + "regex", + "serde", + "serde_json", + "serde_json_path_core", + "serde_json_path_macros", + "thiserror 2.0.17", +] + +[[package]] +name = "serde_json_path_core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde67d8dfe7d4967b5a95e247d4148368ddd1e753e500adb34b3ffe40c6bc1bc" +dependencies = [ + "inventory", + "serde", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "serde_json_path_macros" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "517acfa7f77ddaf5c43d5f119c44a683774e130b4247b7d3210f8924506cfac8" +dependencies = [ + "inventory", + "serde_json_path_core", + "serde_json_path_macros_internal", +] + +[[package]] +name = "serde_json_path_macros_internal" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aafbefbe175fa9bf03ca83ef89beecff7d2a95aaacd5732325b90ac8c3bd7b90" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +dependencies = [ + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "slug" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "flate2", + "fnv", + "once_cell", + "onig", + "regex-syntax", + "serde", + "serde_derive", + "thiserror 2.0.17", + "walkdir", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tera" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8004bca281f2d32df3bacd59bc67b312cb4c70cea46cbd79dbe8ac5ed206722" +dependencies = [ + "chrono", + "chrono-tz", + "globwalk", + "humansize", + "lazy_static", + "percent-encoding", + "pest", + "pest_derive", + "rand", + "regex", + "serde", + "serde_json", + "slug", + "unicode-segmentation", +] + +[[package]] +name = "termstack" +version = "0.1.0" +dependencies = [ + "ansi-to-tui", + "anyhow", + "async-trait", + "chrono", + "clap", + "color-eyre", + "crossterm", + "humansize", + "humantime", + "ratatui 0.29.0", + "regex", + "reqwest", + "serde", + "serde_json", + "serde_json_path", + "serde_yaml", + "tera", + "thiserror 1.0.69", + "tokio", + "tui-input", + "tui-syntax-highlight", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" dependencies = [ "pin-project-lite", "tracing-core", @@ -631,15 +2428,53 @@ dependencies = [ ] [[package]] -name = "tracing-subscriber" -version = "0.3.22" +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tui-input" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd137780d743c103a391e06fe952487f914b299a4fe2c3626677f6a6339a7c6b" +dependencies = [ + "ratatui 0.28.1", + "unicode-width 0.1.14", +] + +[[package]] +name = "tui-syntax-highlight" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b628410d3ef515b09e9895d2ff1713081eccfe757ba7561287cbaae802a8004" +dependencies = [ + "ratatui 0.29.0", + "syntect", +] + +[[package]] +name = "typenum" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" -dependencies = [ - "sharded-slab", - "thread_local", - "tracing-core", -] +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "unicode-ident" @@ -676,18 +2511,162 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" @@ -704,25 +2683,116 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -740,14 +2810,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -756,44 +2843,207 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 02bc72a..4e16510 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,10 +7,41 @@ license = "MIT" edition = "2024" [dependencies] +# TUI Framework crossterm = "0.28.1" ratatui = "0.29.0" color-eyre = "0.6.3" +# Async Runtime +tokio = { version = "1", features = ["full"] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" + +# Data & Templates +tera = "1.20" +serde_json_path = "0.7" +reqwest = { version = "0.12", features = ["json"] } + +# CLI +clap = { version = "4", features = ["derive"] } + +# Utilities +anyhow = "1" +thiserror = "1" +async-trait = "0.1" +chrono = "0.4" +humantime = "2" +humansize = "2" +tui-input = "0.10" +regex = "1" +ansi-to-tui = "7" + +# Syntax highlighting +tui-syntax-highlight = "0.1" + # Read the optimization guideline for more details: https://ratatui.rs/recipes/apps/release-your-app/#optimizations [profile.release] codegen-units = 1 diff --git a/README.md b/README.md index 8e7e0f0..657aeb6 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,479 @@ -# termstack +

+ TermStack Logo +

-This is a [Ratatui] app generated by the [Simple Template]. +

TermStack

-[Ratatui]: https://ratatui.rs -[Simple Template]: https://github.com/ratatui/templates/tree/main/simple +

+ Build beautiful TUIs with YAML. No code. Just vibes. +

+ +

+ Features • + Quick Start • + Examples • + Configuration • + Keybindings • + Contributing +

+ +

+ Rust Version + License + PRs Welcome + Made with Coffee +

+ +--- + +> **"Why write 1000 lines of Rust when you can write 50 lines of YAML?"** +> +> — Someone who's definitely not lazy, just efficient + +TermStack is a config-driven Terminal User Interface (TUI) framework that lets you create powerful terminal dashboards using simple YAML configuration files. Inspired by the legendary [k9s](https://k9scli.io/), but for *everything*. + +Think of it as "k9s for anything" — Kubernetes, REST APIs, dog breeds (yes, really), your custom CLI tools, or that weird internal API your company built in 2015 that nobody wants to touch. + +## Demos + +### CLI Adapter (Kubernetes Dashboard) + +![CLI Demo](assets/cli-demo.gif) + +### HTTP Adapter (Dog API Browser) + +![API Demo](assets/api-demo.gif) + +## Features + +- **Config-driven** — Define pages, data sources, views, and actions in YAML. Your keyboard will thank you. +- **Multiple Data Adapters** — HTTP APIs, CLI commands, scripts, and streaming data. We don't discriminate. +- **Rich Views** — Tables, text, logs, YAML views. Make your terminal pretty (finally). +- **Template Engine** — Tera templates for dynamic content. `{{ variables }}` everywhere! +- **Navigation** — Drill down into data like you're mining for Bitcoin, but actually useful. +- **Conditional Routing** — Different pages for different data types. Files vs folders? We got you. +- **Actions** — Execute commands, delete stuff (with confirmation, we're not monsters), refresh data. +- **Styling** — Color-code everything. Because life's too short for monochrome terminals. +- **Async** — Non-blocking data fetching. Your UI stays responsive while we do the heavy lifting. + +## Quick Start + +### Installation + +```bash +# Clone the repository +git clone https://github.com/pa/termstack.git +cd termstack + +# Build it (grab a coffee, Rust compilation needs it) +cargo build --release + +# The binary is now at ./target/release/termstack +``` + +### Your First TUI in 30 Seconds + +Create `hello.yaml`: + +```yaml +version: v1 + +app: + name: "My First TUI" + description: "Look mom, no code!" + +start: main + +pages: + main: + title: "Hello, Terminal!" + data: + adapter: cli + command: "echo" + args: ['[{"message": "Welcome to TermStack!", "status": "awesome"}]'] + items: "$[*]" + view: + type: table + columns: + - path: "$.message" + display: "Message" + width: 40 + - path: "$.status" + display: "Status" + width: 20 + style: + - default: true + color: green + bold: true +``` + +Run it: + +```bash +cargo run -- hello.yaml +``` + +Congratulations! You just built a TUI without writing a single line of code. Your CS professor would be so proud (or horrified, either way). + +### Usage + +```bash +termstack [OPTIONS] + +Arguments: + Path to the YAML configuration file + +Options: + -v, --validate Validate config and exit (for the paranoid) + -V, --verbose Verbose output (for debugging those 3 AM sessions) + -h, --help Print help +``` + +## Examples + +### Dog Breeds Browser (Real API, No Auth!) + +Browse dog breeds like you're on Tinder, but for pets: + +```bash +cargo run -- examples/dog-api.yaml +``` + +Uses the amazing [DogAPI](https://dogapi.dog/) — a free, open API with no authentication required. Perfect for demos, testing, or just learning about Corgis at 2 AM. + +### Kubernetes Dashboard (Because k9s wasn't enough) + +A k9s-style interface for when you need YAML-ception: + +```bash +cargo run -- examples/kubernetes-cli.yaml +``` + +Navigate Namespaces → Pods → Logs → Existential Crisis about your YAML indentation. + +### More Examples + +| Example | Description | Command | +|---------|-------------|---------| +| `dog-api.yaml` | Browse dog breeds and facts | `cargo run -- examples/dog-api.yaml` | +| `kubernetes-cli.yaml` | Kubernetes resource browser | `cargo run -- examples/kubernetes-cli.yaml` | +| `stream-test.yaml` | Streaming logs demo | `cargo run -- examples/stream-test.yaml` | +| `style-test.yaml` | Styling capabilities | `cargo run -- examples/style-test.yaml` | + +## Configuration + +### Basic Structure + +```yaml +version: v1 + +app: + name: "App Name" + description: "What it does" + theme: "default" # We have themes! (just the one, but it's nice) + +globals: + api_base: "https://api.example.com" + # Variables accessible everywhere as {{ variable_name }} + +start: main_page # Where the magic begins + +pages: + main_page: + title: "Page Title" + data: + adapter: http # or cli, script, stream + url: "{{ api_base }}/endpoint" + items: "$.data[*]" # JSONPath is your friend + view: + type: table + columns: + - path: "$.name" + display: "Name" + width: 30 + next: + page: detail_page + context: + item_id: "$.id" +``` + +### Data Adapters + +#### HTTP — For REST APIs + +```yaml +data: + adapter: http + url: "https://dogapi.dog/api/v2/breeds" + method: GET + headers: + Accept: "application/json" + items: "$.data[*]" + timeout: "30s" + refresh_interval: "5m" # Auto-refresh! +``` + +#### CLI — For shell commands + +```yaml +data: + adapter: cli + command: "kubectl" + args: ["get", "pods", "-o", "json"] + items: "$.items[*]" +``` + +#### Stream — For real-time data + +```yaml +data: + type: stream + command: "kubectl" + args: ["logs", "-f", "my-pod"] + buffer_size: 100 + follow: true +``` + +### Views + +**Table** — The workhorse: +```yaml +view: + type: table + columns: + - path: "$.name" + display: "Name" + width: 30 + style: + - condition: "{{ value == 'active' }}" + color: green + - default: true + color: white +``` + +**Text** — For detailed views: +```yaml +view: + type: text + syntax: yaml # Syntax highlighting! +``` + +**Logs** — For streaming: +```yaml +view: + type: logs + follow: true + wrap: true +``` + +### Navigation + +**Simple** (Enter key): +```yaml +next: + page: detail_page + context: + item_id: "$.id" +``` + +**Conditional** (Smart routing): +```yaml +next: + - condition: "{{ row.type == 'folder' }}" + page: folder_view + - condition: "{{ row.type == 'file' }}" + page: file_view + - default: true + page: fallback +``` + +### Actions + +Press `a` to enter action mode, then the action key: + +```yaml +actions: + - key: "d" + name: "Delete" + confirm: "Really delete {{ name }}? (no undo!)" + command: "kubectl" + args: ["delete", "pod", "{{ name }}"] + refresh: true + - key: "v" + name: "View Details" + page: "detail_page" +``` + +### Styling + +Make it pretty: + +```yaml +style: + - condition: "{{ value == 'Running' }}" + color: green + bold: true + - condition: "{{ value == 'Failed' }}" + color: red + - default: true + color: gray +``` + +Available colors: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, `gray` + +### Template Filters + +```yaml +# Time ago +transform: "{{ value | timeago }}" # "2 hours ago" + +# File size +transform: "{{ value | filesizeformat }}" # "1.5 MB" + +# String manipulation +transform: "{{ value | upper }}" # "SHOUTING" +``` + +## Keybindings + +| Key | Action | +|-----|--------| +| `j` / `↓` | Move down | +| `k` / `↑` | Move up | +| `g` | Go to top | +| `G` | Go to bottom | +| `Enter` | Select / Navigate | +| `Esc` | Go back | +| `/` | Search | +| `a` | Action mode | +| `r` | Refresh | +| `q` | Quit | + +## Claude Code Integration + +TermStack includes a Claude Code skill that auto-generates YAML configurations from natural language prompts. Because writing YAML is so 2024. + +### Using the Skill + +In Claude Code, just run: + +``` +/termstack "browse the Dog API and show breeds with their life expectancy" +``` + +Claude will: +1. Research the API (if needed) +2. Generate a complete, working YAML config +3. Save it to `examples/` +4. Give you the command to run it + +### What It Generates + +The skill knows about all TermStack features: +- HTTP, CLI, Script, and Stream adapters +- Table, Text, and Logs views +- Conditional navigation and actions +- Styling, transforms, and filters +- Multi-page navigation with context passing + +No more copy-pasting from docs. Just describe what you want and let Claude figure out the YAML indentation (the hardest part, honestly). + +## Architecture + +Built with Rust and love: + +- **[ratatui](https://ratatui.rs/)** — Terminal UI framework (the good stuff) +- **[tera](https://tera.netlify.app/)** — Template engine (Jinja2, but Rusty) +- **[tokio](https://tokio.rs/)** — Async runtime (zoom zoom) +- **[serde](https://serde.rs/)** — Serialization (YAML → Rust magic) +- **[reqwest](https://docs.rs/reqwest/)** — HTTP client (fetch all the things) + +## Open Source APIs Used for Testing + +Big shoutout to these awesome free APIs that made testing TermStack a joy: + +| API | Description | Auth | Link | +|-----|-------------|------|------| +| **DogAPI** | Dog breeds, facts, and groups | None | [dogapi.dog](https://dogapi.dog/) | +| **JSONPlaceholder** | Fake REST API for testing | None | [jsonplaceholder.typicode.com](https://jsonplaceholder.typicode.com/) | +| **httpbin** | HTTP request & response testing | None | [httpbin.org](https://httpbin.org/) | + +## Fun Facts + +- TermStack was born because someone got tired of writing the same table rendering code for the 47th time +- The first working prototype was built entirely on coffee and spite +- "YAML" stands for "YAML Ain't Markup Language" and we're not sorry about the recursion +- The `q` key quits because `quit` has too many letters +- Every bug is a feature waiting to be documented + +## Contributing + +We welcome contributions! Here's how: + +1. Fork the repo +2. Create a branch (`git checkout -b feature/amazing-feature`) +3. Make your changes +4. Run tests (`cargo test`) +5. Commit (`git commit -m 'Add amazing feature'`) +6. Push (`git push origin feature/amazing-feature`) +7. Open a PR + +### Development + +```bash +# Build +cargo build + +# Test +cargo test + +# Check (fast compile check) +cargo check + +# Run with example +cargo run -- examples/dog-api.yaml +``` + +## Troubleshooting + +**Q: My YAML isn't working!** + +A: Check your indentation. Then check it again. YAML is 90% indentation anxiety. + +**Q: The TUI is blank!** + +A: Make sure your data source is accessible. Try `--verbose` for debug output. + +**Q: Actions aren't triggering!** + +A: Press `a` first to enter action mode, then your action key. + +**Q: Can I use this in production?** + +A: Technically yes. Should you? Ask your manager, not us. ## License -Copyright (c) pa +MIT License — Do whatever you want, just don't blame us. + +## Author + +**Pramod Hayyappan** ([@pa](https://github.com/pa)) + +Built entirely with Claude (AI pair programming) without any prior Rust knowledge. Yes, you read that right - zero Rust experience, just vibes and AI. The future is here, and it writes Rust for you. + +## Acknowledgments + +- [k9s](https://k9scli.io/) — The inspiration for this madness +- [ratatui](https://ratatui.rs/) — Making terminal UIs actually fun +- Coffee — The real MVP + +--- -This project is licensed under the MIT license ([LICENSE] or ) +

+ Made with mass amounts of mass by a developer who believes terminals deserve better UX +

-[LICENSE]: ./LICENSE +

+ If you read this far, you deserve a cookie. Go get one. +

diff --git a/assets/api-demo.gif b/assets/api-demo.gif new file mode 100644 index 0000000..46e5419 Binary files /dev/null and b/assets/api-demo.gif differ diff --git a/assets/cli-demo.gif b/assets/cli-demo.gif new file mode 100644 index 0000000..8963b96 Binary files /dev/null and b/assets/cli-demo.gif differ diff --git a/assets/termstack.PNG b/assets/termstack.PNG new file mode 100644 index 0000000..3f3eeb6 Binary files /dev/null and b/assets/termstack.PNG differ diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md new file mode 100644 index 0000000..ddeee85 --- /dev/null +++ b/docs/SPECIFICATION.md @@ -0,0 +1,1742 @@ +# TermStack - Specification Document + +**Version**: 1.0.0 +**Date**: 2025-12-01 +**Status**: Draft + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Goals & Objectives](#goals--objectives) +3. [Architecture](#architecture) +4. [Technology Stack](#technology-stack) +5. [Configuration Schema](#configuration-schema) +6. [Core Components](#core-components) +7. [User Experience & Keybindings](#user-experience--keybindings) +8. [Data Flow](#data-flow) +9. [Error Handling](#error-handling) +10. [Phase 1 Features](#phase-1-features) +11. [Phase 2 Features](#phase-2-features) +12. [Implementation Plan](#implementation-plan) + +--- + +## Overview + +**TermStack** is a generic TUI (Terminal User Interface) framework for building rich, navigable dashboards using simple YAML configuration files. Inspired by k9s, it enables developers to create powerful terminal applications without writing UI code. + +### Key Features + +- **Config-driven**: Define pages, data sources, views, and actions in YAML +- **Dynamic rendering**: Automatically renders tables, details, logs, and YAML views +- **Multi-source data**: Fetch from CLI commands or HTTP endpoints +- **Template engine**: Use Tera templates for dynamic content and variable interpolation +- **Navigation stack**: Navigate between pages with context passing +- **Action system**: Execute CLI commands, HTTP requests, or Lua scripts +- **Adapter system**: Extend functionality with plugins +- **Hot reload**: Update config without restarting (Phase 2) + +--- + +## Goals & Objectives + +### Primary Goals + +1. **Zero UI Code**: Users should define TUI apps purely through YAML configuration +2. **k9s-like UX**: Provide familiar navigation patterns (table → detail → logs → back) +3. **Extensibility**: Support plugins and custom actions +4. **Performance**: Async data fetching with responsive UI +5. **Developer Experience**: Clear error messages, validation, and testing tools + +### Non-Goals + +- Not a general-purpose UI framework (use ratatui directly for complex UIs) +- Not a replacement for dedicated tools (k9s for Kubernetes, lazydocker for Docker) +- Not a web framework or HTTP server + +--- + +## Architecture + +### High-Level Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ User Input │ +│ (Keyboard Events) │ +└────────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ App State Machine │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Router │ │ Nav Stack │ │ Context │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└────────┬────────────────────────────────────┬───────────┘ + │ │ + ▼ ▼ +┌─────────────────────┐ ┌─────────────────────┐ +│ Data Providers │ │ View Renderers │ +│ ┌───────────────┐ │ │ ┌───────────────┐ │ +│ │ CLI Provider │ │ │ │ Table View │ │ +│ │ HTTP Provider │ │ │ │ Detail View │ │ +│ │ Stream │ │ │ │ Logs View │ │ +│ └───────────────┘ │ │ │ YAML View │ │ +│ │ │ │ └───────────────┘ │ +│ ▼ │ └─────────────────────┘ +│ ┌───────────────┐ │ +│ │ JSONPath │ │ +│ │ Cache │ │ +│ └───────────────┘ │ +└─────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Template Engine (Tera) │ +└─────────────────────────────────────────────────────────┘ +``` + +### Directory Structure + +``` +termstack/ +├── Cargo.toml +├── README.md +├── SPECIFICATION.md +├── LICENSE +├── examples/ +│ ├── raptor.yaml +│ ├── kubernetes.yaml +│ └── docker.yaml +├── src/ +│ ├── main.rs # CLI entry point +│ ├── app.rs # Main app state machine +│ │ +│ ├── config/ +│ │ ├── mod.rs +│ │ ├── schema.rs # Serde structs for YAML +│ │ ├── loader.rs # Load & validate config +│ │ ├── validator.rs # Validation rules +│ │ └── defaults.rs # Default keybindings, themes +│ │ +│ ├── data/ +│ │ ├── mod.rs +│ │ ├── provider.rs # DataProvider trait +│ │ ├── cli.rs # Execute shell commands +│ │ ├── http.rs # HTTP requests (reqwest) +│ │ ├── stream.rs # Streaming data (logs) [Phase 2] +│ │ ├── cache.rs # TTL cache +│ │ └── jsonpath.rs # JSONPath extraction +│ │ +│ ├── navigation/ +│ │ ├── mod.rs +│ │ ├── router.rs # Page router +│ │ ├── stack.rs # Navigation history +│ │ └── context.rs # Context storage +│ │ +│ ├── view/ +│ │ ├── mod.rs +│ │ ├── renderer.rs # ViewRenderer trait +│ │ ├── table.rs # Table view (ratatui Table) +│ │ ├── detail.rs # Detail/key-value view +│ │ ├── logs.rs # Log streaming view [Phase 2] +│ │ ├── yaml.rs # YAML/JSON viewer +│ │ └── help.rs # Help overlay +│ │ +│ ├── template/ +│ │ ├── mod.rs +│ │ ├── engine.rs # Tera template engine +│ │ └── filters.rs # Custom filters (timeago, etc) +│ │ +│ ├── action/ +│ │ ├── mod.rs +│ │ ├── executor.rs # Execute actions (CLI/HTTP/Lua) +│ │ ├── lua_runtime.rs # Lua integration (mlua) [Phase 2] +│ │ └── builtins.rs # Built-in actions +│ │ +│ ├── adapter/ # [Phase 2] +│ │ ├── mod.rs +│ │ ├── loader.rs # Load adapters +│ │ ├── manifest.rs # Adapter manifest parsing +│ │ └── registry.rs # Adapter registry +│ │ +│ ├── ui/ +│ │ ├── mod.rs +│ │ ├── layout.rs # Layout manager +│ │ ├── theme.rs # Color schemes +│ │ ├── statusbar.rs # Status bar widget +│ │ ├── toast.rs # Toast notifications +│ │ └── breadcrumb.rs # Navigation breadcrumbs +│ │ +│ ├── input/ +│ │ ├── mod.rs +│ │ ├── handler.rs # Key event handling +│ │ ├── search.rs # Search mode +│ │ └── command.rs # Command mode +│ │ +│ └── util/ +│ ├── mod.rs +│ ├── hotreload.rs # File watching (notify) [Phase 2] +│ └── export.rs # Data export [Phase 2] +│ +└── tests/ + ├── integration/ + └── fixtures/ +``` + +--- + +## Technology Stack + +### Core Dependencies + +```toml +[dependencies] +# TUI Framework +crossterm = "0.28" # Terminal control +ratatui = "0.29" # TUI framework +color-eyre = "0.6" # Error handling + +# Async Runtime +tokio = { version = "1", features = ["full"] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" + +# Data & Templates +tera = "1.20" # Template engine +serde_json_path = "0.6" # JSONPath queries +reqwest = { version = "0.12", features = ["json"] } # HTTP client + +# CLI +clap = { version = "4", features = ["derive"] } + +# Utilities +anyhow = "1" # Error handling +thiserror = "1" # Custom errors +chrono = "0.4" # Date/time +humantime = "2" # Duration parsing +humansize = "2" # File size formatting +tui-input = "0.9" # Input widget + +# Phase 2 +mlua = { version = "0.9", features = ["lua54", "async"] } # Lua scripting +notify = "6" # File watching +syntect = "5" # Syntax highlighting +``` + +### Why These Choices? + +- **Ratatui**: Industry-standard TUI framework, excellent docs, active community +- **Tera**: More powerful than Handlebars, better error messages +- **serde_json_path**: Most mature JSONPath implementation in Rust +- **Tokio**: De facto async runtime for Rust +- **mlua**: Safe Lua embedding for scripting (Phase 2) + +--- + +## Configuration Schema + +### Complete YAML Schema + +```yaml +version: v1 + +# Application metadata +app: + name: "Application Name" + description: "Optional description" + theme: "default" # default | nord | dracula | custom + refresh_interval: "30s" # Optional auto-refresh + history_size: 50 # Navigation stack size + +# Global variables accessible via {{ globals.var }} +globals: + api_url: "https://api.example.com" + environment: "prod" + custom_var: "value" + +# Custom keybindings (optional, extends defaults) +keybindings: + global: + "Ctrl+q": quit + "F1": help + custom: + "x": my_custom_action + +# Entry page +start: page_id + +# Page definitions +pages: + page_id: + # Page metadata + title: "{{ page.title }}" + description: "Optional description shown in help" + + # Data source configuration + data: + # === Single Source === + type: cli | http | stream + + # CLI Source + command: "command_name" + args: ["arg1", "{{ context.var }}"] + shell: false # Run in shell vs direct exec + working_dir: "/path" # Optional working directory + env: # Optional environment variables + VAR: "value" + + # HTTP Source + url: "{{ globals.api_url }}/endpoint" + method: GET # GET | POST | PUT | DELETE | PATCH + headers: + Authorization: "Bearer {{ token }}" + Content-Type: "application/json" + body: '{"key": "{{ value }}"}' + + # Data Extraction + items: "$.data[*]" # JSONPath for array extraction + timeout: "30s" + cache: "5m" # Cache TTL (optional) + + # === OR Multiple Sources === + sources: + - id: main + type: cli + command: "..." + - id: supplemental + type: http + url: "..." + optional: true # Don't fail if unavailable + merge: true # Merge sources into single dataset + + # View configuration + view: + layout: table | detail | logs | yaml + + # === TABLE LAYOUT === + columns: + - path: "$.field" # JSONPath to field + display: "Column Name" + width: 20 # Fixed width (optional) + align: left # left | center | right + transform: "{{ value | upper }}" # Tera filter + style: + - condition: "{{ value == 'active' }}" + color: green + bold: true + - default: + color: white + + # Table Options + sort: + column: "$.name" + order: asc # asc | desc + group_by: "$.category" # Group rows by field + selectable: true # Enable row selection + multi_select: false # Allow multi-row selection + + # Row-level Styling + row_style: + - condition: "{{ status == 'error' }}" + color: red + - condition: "{{ disabled }}" + dim: true + + # === DETAIL LAYOUT === + sections: + - title: "Section Title" + fields: + "Label": "$.path" + "Another": "{{ value | transform }}" + + # OR simple flat fields + fields: + "Label": "$.path" + "Another Label": "$.another.path" + + # === LOGS LAYOUT === [Phase 2] + follow: true # Auto-scroll to bottom + wrap: false # Line wrapping + syntax: auto # auto | json | yaml | none + filters: + - name: "Errors" + key: "e" + pattern: "ERROR|FATAL" + + # === YAML LAYOUT === + # (No additional config, shows raw data) + + # Navigation + next: + # === Simple Navigation === + page: next_page_id + context: + var_name: "$.field" # Capture from selected row + + # === OR Conditional Routing === + - condition: "{{ type == 'deployment' }}" + page: deployment_page + - condition: "{{ type == 'service' }}" + page: service_page + - default: default_page + + # Actions (key bindings) + actions: + - key: "d" + name: "Delete" + description: "Delete selected item" + confirm: "Delete {{ name }}?" + + # === CLI Action === + command: "kubectl delete {{ kind }} {{ name }}" + success_message: "Deleted {{ name }}" + error_message: "Failed to delete" + refresh: true # Reload page after action + + # === HTTP Action === [Phase 2] + http: + method: DELETE + url: "{{ globals.api }}/items/{{ id }}" + headers: {} + body: "" + + # === Lua Script === [Phase 2] + script: | + local input = prompt("Enter value:") + if input then + exec("command " .. input) + return "Success!" + end + + # === Navigation Action === + page: another_page + context: + var: "{{ value }}" + + # Built-in actions (always available) + - key: "y" + name: "YAML View" + builtin: yaml_view +``` + +### Schema Validation Rules + +1. **Required Fields**: + - `version`: Must be "v1" + - `app.name`: Non-empty string + - `start`: Must reference existing page + - `pages`: At least one page defined + - Each page must have: `title`, `data`, `view` + +2. **Type Constraints**: + - `data.type`: Must be "cli", "http", or "stream" + - `view.layout`: Must be "table", "detail", "logs", or "yaml" + - `timeout`: Must be valid duration (e.g., "30s", "5m") + - `cache`: Must be valid duration + +3. **Reference Validation**: + - `start` must point to existing page + - `next.page` must point to existing page + - `actions[].page` must point to existing page + +4. **JSONPath Validation**: + - All `path` fields must be valid JSONPath expressions + - `items` must be valid JSONPath (preferably array selector) + +5. **Template Validation**: + - All template strings must be valid Tera syntax + - Variables must be in scope (from context, globals, or current data) + +--- + +## Core Components + +### 1. App State Machine + +**File**: `src/app.rs` + +```rust +pub struct App { + config: Config, + router: Router, + navigation_stack: NavigationStack, + context: Context, + data_cache: DataCache, + current_page: String, + view_state: ViewState, + input_mode: InputMode, + toast_manager: ToastManager, + template_engine: TemplateEngine, + running: bool, +} + +pub enum InputMode { + Normal, + Search(String), + Command(String), + Confirm(ConfirmDialog), +} + +pub enum ViewState { + Loading, + Loaded(ViewData), + Error(String), +} +``` + +### 2. Configuration System + +**File**: `src/config/schema.rs` + +```rust +#[derive(Debug, Deserialize, Serialize)] +pub struct Config { + pub version: String, + pub app: AppConfig, + pub globals: HashMap, + pub keybindings: Option, + pub start: String, + pub pages: HashMap, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Page { + pub title: String, + pub description: Option, + pub data: DataSource, + pub view: View, + pub next: Option, + pub actions: Option>, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum DataSource { + Cli(CliSource), + Http(HttpSource), + Stream(StreamSource), // Phase 2 + Multi(MultiSource), // Multiple sources +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(tag = "layout", rename_all = "lowercase")] +pub enum View { + Table(TableView), + Detail(DetailView), + Logs(LogsView), // Phase 2 + Yaml(YamlView), +} +``` + +### 3. Data Provider System + +**File**: `src/data/provider.rs` + +```rust +#[async_trait] +pub trait DataProvider: Send + Sync { + async fn fetch(&self, context: &Context) -> Result>; +} + +pub struct CliProvider { + command: String, + args: Vec, + shell: bool, + working_dir: Option, + env: HashMap, + timeout: Duration, +} + +pub struct HttpProvider { + url: String, + method: Method, + headers: HeaderMap, + body: Option, + timeout: Duration, +} +``` + +**File**: `src/data/jsonpath.rs` + +```rust +pub struct JsonPathExtractor { + path: JsonPath, +} + +impl JsonPathExtractor { + pub fn new(path: &str) -> Result { + let path = JsonPath::parse(path)?; + Ok(Self { path }) + } + + pub fn extract(&self, data: &Value) -> Result> { + // Extract array of items using JSONPath + } + + pub fn extract_single(&self, data: &Value) -> Result { + // Extract single value + } +} +``` + +### 4. Navigation System + +**File**: `src/navigation/router.rs` + +```rust +pub struct Router { + config: Arc, + current_page: String, +} + +impl Router { + pub fn resolve_page(&self, page_id: &str) -> Result<&Page> { + self.config.pages.get(page_id) + .ok_or_else(|| anyhow!("Page not found: {}", page_id)) + } + + pub fn resolve_next(&self, page: &Page, selected_row: &Value) -> Result { + // Resolve conditional navigation + } +} +``` + +**File**: `src/navigation/stack.rs` + +```rust +pub struct NavigationStack { + frames: Vec, + max_size: usize, +} + +#[derive(Debug, Clone)] +pub struct NavigationFrame { + pub page_id: String, + pub context: HashMap, + pub scroll_state: usize, + pub selected_index: usize, +} + +impl NavigationStack { + pub fn push(&mut self, frame: NavigationFrame) { + if self.frames.len() >= self.max_size { + self.frames.remove(0); + } + self.frames.push(frame); + } + + pub fn pop(&mut self) -> Option { + self.frames.pop() + } + + pub fn current(&self) -> Option<&NavigationFrame> { + self.frames.last() + } +} +``` + +**File**: `src/navigation/context.rs` + +```rust +pub struct Context { + // Navigation history contexts: page_name -> selected row data + page_contexts: HashMap, + // Global variables + globals: HashMap, +} + +impl Context { + pub fn set_page_context(&mut self, page: &str, data: Value) { + self.page_contexts.insert(page.to_string(), data); + } + + pub fn get(&self, path: &str) -> Option<&Value> { + // Resolve {{ page.field }} or {{ globals.var }} + // Example: "projects.name" -> page_contexts["projects"]["name"] + } + + pub fn to_tera_context(&self) -> tera::Context { + // Convert to Tera context for template rendering + } +} +``` + +### 5. View Rendering System + +**File**: `src/view/renderer.rs` + +```rust +pub trait ViewRenderer { + fn render(&mut self, frame: &mut Frame, area: Rect, data: &[Value]); + fn handle_input(&mut self, key: KeyEvent) -> ViewAction; + fn get_selected(&self) -> Option<&Value>; +} + +pub enum ViewAction { + None, + Navigate(String, Context), + ExecuteAction(String), + Back, + Quit, + Search, + YamlView, + Refresh, +} +``` + +**File**: `src/view/table.rs` + +```rust +pub struct TableView { + config: TableViewConfig, + rows: Vec, + selected_index: usize, + scroll_offset: usize, + search_filter: Option, +} + +impl TableView { + pub fn new(config: TableViewConfig) -> Self { /* ... */ } + + fn render_table(&self, frame: &mut Frame, area: Rect) { + // Use ratatui::widgets::Table + // Apply column config, styling, sorting + } + + fn apply_filter(&self, rows: &[Value]) -> Vec { + // Apply search filter + } + + fn apply_sort(&self, rows: &mut [Value]) { + // Apply sorting + } +} + +impl ViewRenderer for TableView { + fn handle_input(&mut self, key: KeyEvent) -> ViewAction { + match key.code { + KeyCode::Char('j') | KeyCode::Down => self.move_down(), + KeyCode::Char('k') | KeyCode::Up => self.move_up(), + KeyCode::Char('g') => self.move_top(), + KeyCode::Char('G') => self.move_bottom(), + KeyCode::Enter => ViewAction::Navigate(/* ... */), + // ... + } + } +} +``` + +**File**: `src/view/detail.rs` + +```rust +pub struct DetailView { + config: DetailViewConfig, + data: Value, + scroll_offset: usize, +} + +impl ViewRenderer for DetailView { + fn render(&mut self, frame: &mut Frame, area: Rect, data: &[Value]) { + // Render key-value pairs or sections + // Use ratatui::widgets::Paragraph or List + } + + fn handle_input(&mut self, key: KeyEvent) -> ViewAction { + // Scrolling only + match key.code { + KeyCode::Char('j') | KeyCode::Down => self.scroll_down(), + KeyCode::Char('k') | KeyCode::Up => self.scroll_up(), + // ... + } + } +} +``` + +**File**: `src/view/yaml.rs` + +```rust +pub struct YamlView { + data: Value, + scroll_offset: usize, + formatted: String, +} + +impl YamlView { + pub fn new(data: Value) -> Self { + let formatted = serde_yaml::to_string(&data).unwrap_or_default(); + Self { + data, + scroll_offset: 0, + formatted, + } + } +} + +impl ViewRenderer for YamlView { + fn render(&mut self, frame: &mut Frame, area: Rect, _data: &[Value]) { + // Render YAML/JSON with syntax highlighting (Phase 2) + // Use ratatui::widgets::Paragraph with scrolling + } +} +``` + +### 6. Template Engine + +**File**: `src/template/engine.rs` + +```rust +pub struct TemplateEngine { + tera: Tera, +} + +impl TemplateEngine { + pub fn new() -> Result { + let mut tera = Tera::default(); + + // Register custom filters + tera.register_filter("timeago", filters::timeago); + tera.register_filter("filesizeformat", filters::filesizeformat); + tera.register_filter("status_color", filters::status_color); + + Ok(Self { tera }) + } + + pub fn render_string(&self, template: &str, context: &Context) -> Result { + let tera_context = context.to_tera_context(); + self.tera.render_str(template, &tera_context) + .map_err(|e| anyhow!("Template error: {}", e)) + } + + pub fn render_value(&self, template: &str, context: &Context) -> Result { + let rendered = self.render_string(template, context)?; + serde_json::from_str(&rendered) + .map_err(|e| anyhow!("JSON parse error: {}", e)) + } +} +``` + +**File**: `src/template/filters.rs` + +```rust +pub fn timeago(value: &Value, _args: &HashMap) -> tera::Result { + // Convert timestamp to "2 hours ago" +} + +pub fn filesizeformat(value: &Value, _args: &HashMap) -> tera::Result { + // Convert bytes to "1.5 GB" +} + +pub fn status_color(value: &Value, _args: &HashMap) -> tera::Result { + // Map status to color name + match value.as_str() { + Some("running") | Some("active") => Ok(Value::String("green".to_string())), + Some("error") | Some("failed") => Ok(Value::String("red".to_string())), + _ => Ok(Value::String("yellow".to_string())), + } +} +``` + +### 7. Action System + +**File**: `src/action/executor.rs` + +```rust +pub struct ActionExecutor { + template_engine: Arc, +} + +impl ActionExecutor { + pub async fn execute(&self, action: &Action, context: &Context) -> Result { + match action { + Action::Cli(cli) => self.execute_cli(cli, context).await, + Action::Http(http) => self.execute_http(http, context).await, + Action::Script(script) => self.execute_script(script, context).await, + Action::Navigation(nav) => self.execute_navigation(nav, context), + Action::Builtin(builtin) => self.execute_builtin(builtin), + } + } + + async fn execute_cli(&self, cli: &CliAction, context: &Context) -> Result { + // Render command and args with templates + let command = self.template_engine.render_string(&cli.command, context)?; + let args: Vec = cli.args.iter() + .map(|arg| self.template_engine.render_string(arg, context)) + .collect::>>()?; + + // Execute command + let output = tokio::process::Command::new(command) + .args(args) + .output() + .await?; + + if output.status.success() { + Ok(ActionResult::Success(cli.success_message.clone())) + } else { + Ok(ActionResult::Error(String::from_utf8_lossy(&output.stderr).to_string())) + } + } +} + +pub enum ActionResult { + Success(Option), + Error(String), + Navigate(String, Context), +} +``` + +### 8. UI Components + +**File**: `src/ui/statusbar.rs` + +```rust +pub struct StatusBar { + current_page: String, + current_mode: InputMode, + shortcuts: Vec<(String, String)>, +} + +impl StatusBar { + pub fn render(&self, frame: &mut Frame, area: Rect) { + // Render bottom status bar + // Format: "[page_name] | Normal | q:quit ?:help /:search" + } +} +``` + +**File**: `src/ui/toast.rs` + +```rust +pub struct ToastManager { + toasts: VecDeque, +} + +pub struct Toast { + message: String, + level: ToastLevel, + created_at: Instant, + duration: Duration, +} + +pub enum ToastLevel { + Info, + Success, + Warning, + Error, +} + +impl ToastManager { + pub fn show(&mut self, message: String, level: ToastLevel) { + self.toasts.push_back(Toast { + message, + level, + created_at: Instant::now(), + duration: Duration::from_secs(3), + }); + } + + pub fn render(&mut self, frame: &mut Frame, area: Rect) { + // Render toasts in top-right corner + // Auto-dismiss after duration + self.toasts.retain(|t| t.created_at.elapsed() < t.duration); + } +} +``` + +**File**: `src/ui/breadcrumb.rs` + +```rust +pub struct Breadcrumb { + navigation_stack: Vec, +} + +impl Breadcrumb { + pub fn render(&self, frame: &mut Frame, area: Rect) { + // Render breadcrumb trail + // Format: "projects > environments > resources" + } +} +``` + +### 9. Input Handling + +**File**: `src/input/handler.rs` + +```rust +pub struct InputHandler { + keybindings: Keybindings, +} + +impl InputHandler { + pub fn handle(&self, key: KeyEvent, mode: &InputMode) -> InputAction { + match mode { + InputMode::Normal => self.handle_normal(key), + InputMode::Search(_) => self.handle_search(key), + InputMode::Command(_) => self.handle_command(key), + InputMode::Confirm(_) => self.handle_confirm(key), + } + } + + fn handle_normal(&self, key: KeyEvent) -> InputAction { + // Check custom keybindings first + // Then default keybindings + match key.code { + KeyCode::Char('q') => InputAction::Quit, + KeyCode::Char('?') => InputAction::ShowHelp, + KeyCode::Char('/') => InputAction::EnterSearch, + KeyCode::Char(':') => InputAction::EnterCommand, + KeyCode::Char('y') => InputAction::YamlView, + KeyCode::Char('r') => InputAction::Refresh, + KeyCode::Esc => InputAction::Back, + _ => InputAction::PassToView(key), + } + } +} + +pub enum InputAction { + None, + Quit, + Back, + ShowHelp, + EnterSearch, + EnterCommand, + YamlView, + Refresh, + PassToView(KeyEvent), + ExecuteAction(String), +} +``` + +**File**: `src/input/search.rs` + +```rust +pub struct SearchMode { + query: String, + cursor: usize, +} + +impl SearchMode { + pub fn handle_key(&mut self, key: KeyEvent) -> SearchAction { + match key.code { + KeyCode::Char(c) => { + self.query.insert(self.cursor, c); + self.cursor += 1; + SearchAction::Update(self.query.clone()) + } + KeyCode::Backspace => { + if self.cursor > 0 { + self.query.remove(self.cursor - 1); + self.cursor -= 1; + } + SearchAction::Update(self.query.clone()) + } + KeyCode::Enter => SearchAction::Execute(self.query.clone()), + KeyCode::Esc => SearchAction::Cancel, + _ => SearchAction::None, + } + } +} + +pub enum SearchAction { + None, + Update(String), + Execute(String), + Cancel, +} +``` + +--- + +## User Experience & Keybindings + +### Default Keybindings + +#### Global (All Modes) + +| Key | Action | Description | +|-----|--------|-------------| +| `q` | Quit | Exit application | +| `?` | Help | Show help overlay | +| `Esc` | Back | Go back to previous page | +| `Ctrl+C` | Force Quit | Immediate exit | + +#### Normal Mode (Navigation) + +| Key | Action | Description | +|-----|--------|-------------| +| `j` / `↓` | Move Down | Select next item | +| `k` / `↑` | Move Up | Select previous item | +| `g` | Go to Top | Jump to first item | +| `G` | Go to Bottom | Jump to last item | +| `Enter` | Navigate | Go to next page / drill down | +| `r` | Refresh | Reload current page data | +| `/` | Search | Enter search mode | +| `:` | Command | Enter command mode | +| `y` | YAML View | Toggle YAML/raw view | +| `Ctrl+R` | Auto-Refresh | Toggle auto-refresh [Phase 2] | +| `h` / `←` | Back | Same as Esc | +| `l` / `→` | Forward | Navigate forward (if available) | + +#### Table View Specific + +| Key | Action | Description | +|-----|--------|-------------| +| `Space` | Select | Toggle row selection (multi-select) | +| `a` | Select All | Select all visible rows | +| `s` | Sort | Cycle sort column | +| `S` | Sort Desc | Reverse sort order | + +#### Detail View Specific + +| Key | Action | Description | +|-----|--------|-------------| +| `j` / `↓` | Scroll Down | Scroll content down | +| `k` / `↑` | Scroll Up | Scroll content up | +| `Ctrl+D` | Page Down | Scroll half page down | +| `Ctrl+U` | Page Up | Scroll half page up | + +#### Search Mode + +| Key | Action | Description | +|-----|--------|-------------| +| `Enter` | Apply | Apply search filter | +| `Esc` | Cancel | Exit search, clear filter | +| `Backspace` | Delete | Delete character | +| `←` / `→` | Move Cursor | Navigate input | + +#### Command Mode [Phase 2] + +| Key | Action | Description | +|-----|--------|-------------| +| `Enter` | Execute | Execute command | +| `Esc` | Cancel | Exit command mode | +| `Tab` | Complete | Auto-complete command | + +### UI Layout + +``` +┌────────────────────────────────────────────────────────┐ +│ App Name [Toast Notification] │ ← Toast (top-right) +├────────────────────────────────────────────────────────┤ +│ projects > environments > resources │ ← Breadcrumb +├────────────────────────────────────────────────────────┤ +│ │ +│ ID Type Status Updated │ ← Table View +│ ── ──── ────── ─────── │ +│ > abc123 deployment running 2h ago │ +│ def456 service pending 5m ago │ +│ ghi789 job completed 1d ago │ +│ │ +│ │ +│ │ +│ │ +├────────────────────────────────────────────────────────┤ +│ [resources] Normal | q:quit ?:help /:search r:refresh │ ← Status Bar +└────────────────────────────────────────────────────────┘ +``` + +### Help Overlay + +``` +┌──────────────────────────────────────┐ +│ TermStack Help │ +├──────────────────────────────────────┤ +│ Navigation │ +│ j/↓ Move down │ +│ k/↑ Move up │ +│ g Go to top │ +│ G Go to bottom │ +│ Enter Drill down / Select │ +│ Esc Go back │ +│ │ +│ Actions │ +│ r Refresh │ +│ / Search │ +│ y YAML view │ +│ ? Toggle help │ +│ q Quit │ +│ │ +│ Custom Actions │ +│ d Delete resource │ +│ l View logs │ +│ │ +│ Press ? or Esc to close │ +└──────────────────────────────────────┘ +``` + +--- + +## Data Flow + +### 1. Application Startup + +``` +┌─────────────┐ +│ main() │ +└──────┬──────┘ + │ + ▼ +┌─────────────────────────┐ +│ Load Config YAML │ +│ - Parse with serde │ +│ - Validate schema │ +│ - Build Config struct │ +└──────┬──────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ Initialize Components │ +│ - Router │ +│ - TemplateEngine │ +│ - DataCache │ +│ - ToastManager │ +└──────┬──────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ Navigate to Start Page │ +└──────┬──────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ Main Event Loop │ +└─────────────────────────┘ +``` + +### 2. Page Navigation Flow + +``` +User presses Enter on selected row + │ + ▼ +┌─────────────────────────────────┐ +│ Capture selected row data │ +│ Store in Context │ +└──────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ Resolve next page │ +│ - Check conditional routing │ +│ - Evaluate Tera conditions │ +└──────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ Push current frame to stack │ +│ - Page ID │ +│ - Context │ +│ - Scroll state │ +└──────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ Fetch data for new page │ +│ (see Data Fetching Flow) │ +└──────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ Render new page │ +└─────────────────────────────────┘ +``` + +### 3. Data Fetching Flow + +``` +Page requests data + │ + ▼ +┌─────────────────────────────────┐ +│ Check cache │ +│ - If cached & fresh, return │ +└──────┬──────────────────────────┘ + │ Cache miss + ▼ +┌─────────────────────────────────┐ +│ Render command/URL template │ +│ - Substitute {{ variables }} │ +│ - Use current Context │ +└──────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ Execute data provider │ +│ - CLI: spawn process │ +│ - HTTP: make request │ +└──────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ Parse response (JSON/YAML) │ +└──────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ Apply JSONPath extraction │ +│ - Extract items array │ +└──────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ Transform & filter data │ +│ - Apply Tera transforms │ +│ - Apply view filters │ +└──────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ Cache result (if TTL set) │ +└──────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ Return to view renderer │ +└─────────────────────────────────┘ +``` + +### 4. Action Execution Flow + +``` +User presses action key (e.g., 'd') + │ + ▼ +┌─────────────────────────────────┐ +│ Lookup action by key │ +└──────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ Show confirmation (if set) │ +│ - Render confirm dialog │ +│ - Wait for y/n input │ +└──────┬──────────────────────────┘ + │ Confirmed + ▼ +┌─────────────────────────────────┐ +│ Show loading toast │ +│ "Executing action..." │ +└──────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ Execute action │ +│ - Render templates │ +│ - Execute CLI/HTTP/Lua │ +└──────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ Handle result │ +│ - Success: show success toast │ +│ - Error: show error toast │ +└──────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ Refresh page (if configured) │ +└─────────────────────────────────┘ +``` + +--- + +## Error Handling + +### Error Types + +```rust +#[derive(Debug, thiserror::Error)] +pub enum TermStackError { + #[error("Config error: {0}")] + Config(String), + + #[error("Validation error: {0}")] + Validation(String), + + #[error("Data provider error: {0}")] + DataProvider(String), + + #[error("Template error: {0}")] + Template(String), + + #[error("Navigation error: {0}")] + Navigation(String), + + #[error("Action execution error: {0}")] + Action(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("YAML error: {0}")] + Yaml(#[from] serde_yaml::Error), +} +``` + +### Error Display Strategy + +1. **Fatal Errors** (Config parse, validation): + - Display error page with details + - Show traceback in debug mode + - Suggest fixes (e.g., "Invalid JSONPath at pages.projects.data.items") + +2. **Runtime Errors** (Data fetch, action execution): + - Show error toast (top-right, 5s) + - Log to status bar + - Allow retry + +3. **Validation Warnings**: + - Show warning toast + - Continue execution + +### Error Message Format + +``` +┌────────────────────────────────────────────┐ +│ ❌ Configuration Error │ +├────────────────────────────────────────────┤ +│ │ +│ Failed to parse YAML config: │ +│ File: raptor.yaml │ +│ Line: 42 │ +│ │ +│ Error: Invalid JSONPath expression │ +│ Field: pages.resources.data.items │ +│ Value: "$.data[*" │ +│ Expected: closing bracket ']' │ +│ │ +│ Suggestion: │ +│ Change "$.data[*" to "$.data[*]" │ +│ │ +│ Press 'q' to quit, 'e' to edit config │ +└────────────────────────────────────────────┘ +``` + +--- + +## Phase 1 Features + +### Must Have (P0) + +- [x] **Config System** + - Load YAML config + - Parse into typed structs (serde) + - Validate schema + - Default values + +- [x] **Data Providers** + - CLI provider (execute commands) + - HTTP provider (GET requests) + - JSON parsing + - JSONPath extraction + - Response caching (TTL) + +- [x] **Template Engine** + - Tera integration + - Context management + - Custom filters (timeago, filesizeformat, status_color) + - Variable interpolation + +- [x] **Navigation** + - Page router + - Navigation stack + - Context passing (selected row data) + - Back navigation + +- [x] **Views** + - Table view + - Column configuration + - Row selection + - Scrolling + - Sorting + - Detail view + - Key-value display + - Sections + - Scrolling + - YAML view + - Raw data display + - Scrolling + +- [x] **Input Handling** + - Default keybindings + - Custom keybindings + - Search mode + - Normal mode navigation + +- [x] **UI Components** + - Status bar + - Toast notifications + - Breadcrumb trail + - Help overlay + +- [x] **Actions** + - CLI command execution + - Confirmation dialogs + - Success/error messages + - Page refresh after action + +- [x] **Error Handling** + - Graceful error display + - Error toast notifications + - Config validation errors + - Runtime error recovery + +### Should Have (P1) + +- [ ] **Advanced Data** + - HTTP POST/PUT/DELETE + - Custom headers + - Request body templates + - Multi-source data composition + +- [ ] **Advanced Views** + - Table grouping + - Conditional row styling + - Column width configuration + - Detail view sections + +- [ ] **Advanced Actions** + - HTTP actions + - Action conditions (show only if...) + - Built-in actions (export, copy, etc.) + +- [ ] **Search & Filter** + - Fuzzy search + - Filter by field + - Regex search + - Search history + +- [ ] **CLI Commands** + - `termstack validate ` - Validate config + - `termstack test --page ` - Test data fetching + - `termstack run ` - Run TUI + - `termstack --help` - Show help + +--- + +## Phase 2 Features + +### Planned for Future + +- [ ] **Log Streaming** + - Stream view layout + - Follow mode + - Line wrapping + - Syntax highlighting + - Log filtering + +- [ ] **Scripting** + - Lua integration (mlua) + - Script actions + - Helper functions (exec, prompt, http) + - Script debugging + +- [ ] **Adapter System** + - Plugin loader + - Adapter manifest + - Custom pages from adapters + - Adapter actions + - Adapter registry + +- [ ] **Hot Reload** + - Watch config file + - Reload on change + - Preserve navigation state + - Notify user of reload + +- [ ] **Multi-Pane Views** + - Split layouts (horizontal/vertical) + - Multiple views per page + - Pane focus switching + +- [ ] **Export & Scripting** + - Export to JSON/CSV + - Headless mode (no TUI) + - Scriptable commands + +- [ ] **Advanced UI** + - Themes (Nord, Dracula, custom) + - Custom colors per page + - Icons & symbols + - Progress bars + +- [ ] **Performance** + - Incremental rendering + - Virtual scrolling for large tables + - Background data refresh + - Connection pooling + +--- + +## Implementation Plan + +### Week 1: Foundation + +**Day 1-2: Project Setup & Config** +- [x] Initialize project structure +- [ ] Define Cargo.toml dependencies +- [ ] Create config schema structs +- [ ] Implement YAML loader +- [ ] Write config validator +- [ ] Add error types + +**Day 3-4: Data Providers** +- [ ] Implement DataProvider trait +- [ ] CLI provider with process spawning +- [ ] HTTP provider with reqwest +- [ ] JSONPath extractor +- [ ] Basic caching layer + +**Day 5-7: Template Engine & Navigation** +- [ ] Integrate Tera +- [ ] Implement custom filters +- [ ] Context management system +- [ ] Router implementation +- [ ] Navigation stack +- [ ] Template rendering in data providers + +### Week 2: Views & Rendering + +**Day 8-10: Table View** +- [ ] TableView struct +- [ ] Ratatui table rendering +- [ ] Row selection logic +- [ ] Scrolling (viewport) +- [ ] Sorting implementation +- [ ] Column styling + +**Day 11-12: Detail & YAML Views** +- [ ] DetailView implementation +- [ ] Sections rendering +- [ ] YamlView implementation +- [ ] Syntax highlighting (basic) +- [ ] Scrolling for both views + +**Day 13-14: UI Components** +- [ ] Status bar widget +- [ ] Toast manager +- [ ] Breadcrumb component +- [ ] Help overlay +- [ ] Layout management + +### Week 3: Input & Actions + +**Day 15-16: Input Handling** +- [ ] Default keybindings +- [ ] Custom keybinding loader +- [ ] Input handler with mode switching +- [ ] Search mode implementation +- [ ] Command mode (basic) + +**Day 17-18: Action System** +- [ ] Action executor +- [ ] CLI action implementation +- [ ] Confirmation dialogs +- [ ] Success/error toasts +- [ ] Page refresh after action + +**Day 19-21: Integration & Main Loop** +- [ ] App state machine +- [ ] Main event loop +- [ ] Async data fetching +- [ ] View state management +- [ ] Error recovery + +### Week 4: Polish & Testing + +**Day 22-23: CLI & Examples** +- [ ] CLI argument parsing (clap) +- [ ] `validate` command +- [ ] `test` command +- [ ] Example configs (raptor, k8s, docker) +- [ ] Documentation + +**Day 24-26: Testing** +- [ ] Unit tests for core components +- [ ] Integration tests +- [ ] Config validation tests +- [ ] Error handling tests +- [ ] Manual testing with example configs + +**Day 27-28: Bug Fixes & Documentation** +- [ ] Fix issues from testing +- [ ] Write README +- [ ] API documentation +- [ ] User guide +- [ ] Contributing guide + +--- + +## Success Metrics + +### Phase 1 Completion Criteria + +1. **Config Loading**: Successfully parse and validate complex YAML configs +2. **Data Fetching**: Execute CLI commands and HTTP requests with template rendering +3. **Navigation**: Navigate through multi-level page hierarchies with context passing +4. **Views**: Render table, detail, and YAML views with proper scrolling +5. **Actions**: Execute CLI actions with confirmations and refresh +6. **UX**: Responsive input handling, search, and k9s-like navigation +7. **Examples**: Working example configs for at least 2 different use cases + +### Performance Targets + +- Config load time: < 100ms +- Page navigation: < 50ms (excluding data fetch) +- Data fetch + render: < 500ms (depends on external command) +- UI responsiveness: 60 FPS +- Memory usage: < 50MB for typical workloads + +--- + +## Appendix + +### A. Example Configs + +See `examples/` directory: +- `raptor.yaml` - Multi-level resource navigation +- `kubernetes.yaml` - Kubernetes resource browser +- `docker.yaml` - Docker container manager + +### B. JSONPath Reference + +Common patterns: +- `$.field` - Top-level field +- `$.nested.field` - Nested field +- `$.array[*]` - All array items +- `$.array[0]` - First item +- `$.array[?(@.status == 'active')]` - Filtered items + +### C. Tera Template Reference + +Variables: +- `{{ variable }}` - Simple variable +- `{{ object.field }}` - Nested field +- `{{ array.0 }}` - Array access + +Filters: +- `{{ value | upper }}` - Uppercase +- `{{ value | lower }}` - Lowercase +- `{{ value | truncate(length=20) }}` - Truncate +- `{{ value | timeago }}` - Time ago (custom) +- `{{ value | filesizeformat }}` - File size (custom) + +Conditions: +- `{% if condition %}...{% endif %}` +- `{% if value == "active" %}...{% else %}...{% endif %}` + +### D. Contribution Guidelines + +See `CONTRIBUTING.md` (to be created) + +--- + +**End of Specification Document** diff --git a/examples/dog-api.yaml b/examples/dog-api.yaml new file mode 100644 index 0000000..fed2462 --- /dev/null +++ b/examples/dog-api.yaml @@ -0,0 +1,318 @@ +# ============================================================================= +# Dog API Browser +# ============================================================================= +# Browse dog breeds and facts using the free DogAPI.dog service. +# No authentication required - perfect for demos and learning! +# +# Usage: cargo run -- examples/dog-api.yaml +# +# This example demonstrates: +# - HTTP adapter for REST API calls +# - JSON:API format handling ($.data[*] extraction) +# - Multi-page navigation with context passing +# - Conditional styling based on values +# - Action shortcuts for quick navigation +# - Detailed views for individual items +# +# API: https://dogapi.dog/docs/api-v2 +# +# Fun fact: There are over 200 dog breeds recognized worldwide. +# This API knows about most of them. Your terminal is about to get adorable. +# ============================================================================= + +version: v1 + +app: + name: "Dog Breeds Browser" + description: "Explore dog breeds and facts" + theme: "default" + +globals: + api_base: "https://dogapi.dog/api/v2" + +start: breeds + +pages: + # -------------------------------------------------------------------------- + # Breeds List - Main landing page + # -------------------------------------------------------------------------- + # Shows all dog breeds in a beautiful table. + # Press Enter to see breed details, or use actions for quick navigation. + breeds: + title: "Dog Breeds" + data: + adapter: http + url: "{{ api_base }}/breeds" + method: GET + headers: + Accept: "application/json" + items: "$.data[*]" + refresh_interval: "10m" + view: + type: table + columns: + - path: "$.attributes.name" + display: "Breed" + width: 30 + style: + - default: true + color: cyan + bold: true + - path: "$.attributes.life.min" + display: "Min Life" + width: 10 + style: + - default: true + color: green + - path: "$.attributes.life.max" + display: "Max Life" + width: 10 + style: + - default: true + color: green + - path: "$.attributes.male_weight.min" + display: "Male Wt (kg)" + width: 12 + style: + - default: true + color: blue + - path: "$.attributes.female_weight.min" + display: "Female Wt (kg)" + width: 14 + style: + - default: true + color: magenta + - path: "$.attributes.hypoallergenic" + display: "Hypo" + width: 6 + style: + - condition: "{{ value == 'true' }}" + color: yellow + bold: true + - default: true + color: gray + sort: + column: "$.attributes.name" + order: asc + next: + page: breed_detail + context: + breed_id: "$.id" + breed_name: "$.attributes.name" + actions: + - key: "f" + name: "View Facts" + description: "View random dog facts" + page: "facts" + - key: "g" + name: "View Groups" + description: "View breed groups" + page: "groups" + + # -------------------------------------------------------------------------- + # Breed Detail - Single breed information + # -------------------------------------------------------------------------- + # Detailed view of a specific breed including weight ranges and description. + # Context: breed_id and breed_name passed from breeds page. + breed_detail: + title: "{{ breed_name }}" + data: + adapter: http + url: "{{ api_base }}/breeds/{{ breed_id }}" + method: GET + headers: + Accept: "application/json" + items: "$.data" + view: + type: table + columns: + - path: "$.attributes.name" + display: "Name" + width: 30 + style: + - default: true + color: cyan + bold: true + - path: "$.attributes.description" + display: "Description" + width: 80 + - path: "$.attributes.life.min" + display: "Min Life (yrs)" + width: 15 + style: + - default: true + color: green + - path: "$.attributes.life.max" + display: "Max Life (yrs)" + width: 15 + style: + - default: true + color: green + - path: "$.attributes.male_weight.min" + display: "Male Min (kg)" + width: 14 + style: + - default: true + color: blue + - path: "$.attributes.male_weight.max" + display: "Male Max (kg)" + width: 14 + style: + - default: true + color: blue + - path: "$.attributes.female_weight.min" + display: "Female Min (kg)" + width: 16 + style: + - default: true + color: magenta + - path: "$.attributes.female_weight.max" + display: "Female Max (kg)" + width: 16 + style: + - default: true + color: magenta + - path: "$.attributes.hypoallergenic" + display: "Hypoallergenic" + width: 15 + style: + - condition: "{{ value == 'true' }}" + color: yellow + bold: true + - default: true + color: gray + actions: + - key: "f" + name: "View Facts" + description: "View random dog facts" + page: "facts" + - key: "y" + name: "View as YAML" + description: "View full JSON response" + page: "breed_yaml" + + # -------------------------------------------------------------------------- + # Breed YAML View - Raw API response + # -------------------------------------------------------------------------- + # Shows the full JSON response formatted as YAML. + # Great for debugging or seeing the complete data structure. + breed_yaml: + title: "{{ breed_name }} (YAML)" + data: + adapter: http + url: "{{ api_base }}/breeds/{{ breed_id }}" + method: GET + headers: + Accept: "application/json" + view: + type: text + syntax: yaml + + # -------------------------------------------------------------------------- + # Dog Facts - Random fun facts about dogs + # -------------------------------------------------------------------------- + # Did you know dogs can smell emotions? Neither did we until this API. + facts: + title: "Dog Facts" + data: + adapter: http + url: "{{ api_base }}/facts" + method: GET + headers: + Accept: "application/json" + items: "$.data[*]" + view: + type: table + columns: + - path: "$.attributes.body" + display: "Fact" + width: 100 + style: + - default: true + color: yellow + next: + page: fact_detail + context: + fact_id: "$.id" + fact_body: "$.attributes.body" + + # -------------------------------------------------------------------------- + # Fact Detail - Single fact view + # -------------------------------------------------------------------------- + fact_detail: + title: "Dog Fact" + data: + adapter: http + url: "{{ api_base }}/facts/{{ fact_id }}" + method: GET + headers: + Accept: "application/json" + view: + type: text + syntax: yaml + + # -------------------------------------------------------------------------- + # Breed Groups - Categories of dog breeds + # -------------------------------------------------------------------------- + # Breeds are organized into groups like Herding, Sporting, Toy, etc. + groups: + title: "Breed Groups" + data: + adapter: http + url: "{{ api_base }}/groups" + method: GET + headers: + Accept: "application/json" + items: "$.data[*]" + view: + type: table + columns: + - path: "$.attributes.name" + display: "Group Name" + width: 50 + style: + - default: true + color: cyan + bold: true + next: + page: group_detail + context: + group_id: "$.id" + group_name: "$.attributes.name" + + # -------------------------------------------------------------------------- + # Group Detail - Breeds in a specific group + # -------------------------------------------------------------------------- + # Shows details about a breed group including how many breeds it contains. + group_detail: + title: "{{ group_name }}" + data: + adapter: http + url: "{{ api_base }}/groups/{{ group_id }}" + method: GET + headers: + Accept: "application/json" + items: "$.data" + view: + type: table + columns: + - path: "$.id" + display: "Group ID" + width: 40 + style: + - default: true + color: blue + - path: "$.attributes.name" + display: "Group Name" + width: 40 + style: + - default: true + color: cyan + bold: true + - path: "$.relationships.breeds.data" + display: "Breeds Count" + width: 15 + transform: "{{ value | length }}" + style: + - default: true + color: green diff --git a/examples/kubernetes-cli.yaml b/examples/kubernetes-cli.yaml new file mode 100644 index 0000000..9c08bd0 --- /dev/null +++ b/examples/kubernetes-cli.yaml @@ -0,0 +1,885 @@ +# ============================================================================= +# Kubernetes Dashboard +# ============================================================================= +# A k9s-inspired interface for navigating Kubernetes resources. +# Because sometimes you need YAML to manage your YAML deployments. +# +# Usage: cargo run -- examples/kubernetes-cli.yaml +# +# Requirements: +# - kubectl installed and configured +# - Access to a Kubernetes cluster (or prepare for errors) +# +# This example demonstrates: +# - CLI adapter with kubectl commands +# - Complex JSONPath for nested data extraction +# - Status-based conditional styling +# - Actions for pod management (delete, logs, describe) +# - Streaming logs with the stream adapter +# - Multiple resource types (pods, deployments, services, RBAC) +# - Transforms for human-readable timestamps +# +# Navigation flow: +# Namespaces -> Pods/Deployments/Services -> Details -> Logs +# +# Pro tip: Press 'a' to see available actions on any page. +# ============================================================================= + +version: v1 + +app: + name: "Kubernetes TUI" + description: "Navigate Kubernetes resources with k9s-style interface" + theme: "default" + +start: namespaces + +pages: + # ============================================================================ + # NAMESPACES - List all namespaces + # ============================================================================ + namespaces: + title: "Namespaces" + description: "List all Kubernetes namespaces" + data: + adapter: cli + command: "kubectl" + args: ["get", "namespaces", "-o", "json"] + items: "$.items[*]" + timeout: "30s" + refresh_interval: "60s" + view: + type: table + columns: + - path: "$.metadata.name" + display: "Name" + width: 40 + style: + - default: true + color: cyan + bold: true + - path: "$.status.phase" + display: "Status" + width: 15 + style: + - condition: "{{ value == 'Active' }}" + color: green + bold: true + - condition: "{{ value == 'Terminating' }}" + color: yellow + - default: true + color: red + - path: "$.metadata.creationTimestamp" + display: "Age" + width: 20 + transform: "{{ value | timeago }}" + style: + - default: true + color: gray + sort: + column: "$.metadata.name" + order: asc + row_style: + - condition: "{{ status.phase == 'Terminating' }}" + dim: true + next: + page: pods + context: + namespace: "$.metadata.name" + actions: + - key: "p" + name: "Pods" + description: "View pods in namespace" + page: pods + context: + namespace: "$.metadata.name" + - key: "d" + name: "Deployments" + description: "View deployments in namespace" + page: deployments + context: + namespace: "$.metadata.name" + - key: "s" + name: "Services" + description: "View services in namespace" + page: services + context: + namespace: "$.metadata.name" + - key: "r" + name: "RBAC" + description: "View RBAC resources" + page: rbac_overview + context: + namespace: "$.metadata.name" + + # ============================================================================ + # PODS - List pods in selected namespace + # ============================================================================ + pods: + title: "Pods - {{ namespaces.metadata.name }}" + description: "List all pods in namespace" + data: + adapter: cli + command: "kubectl" + args: + ["get", "pods", "-n", "{{ namespaces.metadata.name }}", "-o", "json"] + items: "$.items[*]" + timeout: "30s" + refresh_interval: "5s" + view: + type: table + columns: + - path: "$.metadata.name" + display: "Name" + width: 45 + style: + - default: true + color: cyan + - path: "$.status.phase" + display: "Status" + width: 12 + style: + - condition: "{{ value == 'Running' }}" + color: green + bold: true + - condition: "{{ value == 'Pending' }}" + color: yellow + - condition: "{{ value == 'Succeeded' }}" + color: blue + - condition: "{{ value == 'Failed' }}" + color: red + bold: true + - condition: "{{ value == 'Unknown' }}" + color: magenta + - default: true + color: white + - path: "$.status.conditions[?(@.type=='Ready')].status" + display: "Ready" + width: 8 + style: + - condition: "{{ value == 'True' }}" + color: green + - condition: "{{ value == 'False' }}" + color: red + - default: true + color: yellow + - path: "$.spec.containers" + display: "Containers" + transform: "{{ value | length }}" + width: 12 + style: + - default: true + color: blue + - path: "$.metadata.creationTimestamp" + display: "Age" + transform: "{{ value | timeago }}" + width: 15 + style: + - default: true + color: gray + row_style: + - condition: "{{ status.phase == 'Failed' }}" + color: red + dim: true + - condition: "{{ status.phase == 'Pending' }}" + dim: true + - condition: "{{ status.phase == 'Running' }}" + default: true + next: + page: pod_detail + context: + pod_name: "$.metadata.name" + namespace: "{{ namespaces.metadata.name }}" + actions: + - key: "l" + name: "Logs" + description: "View pod logs" + page: pod_logs + context: + pod_name: "$.metadata.name" + namespace: "{{ namespaces.metadata.name }}" + - key: "d" + name: "Delete" + description: "Delete pod" + confirm: "Delete pod {{ metadata.name }}?" + command: "kubectl" + args: + [ + "delete", + "pod", + "{{ metadata.name }}", + "-n", + "{{ namespaces.metadata.name }}", + ] + notification: + on_success: "Pod '{{ metadata.name }}' deleted successfully" + on_failure: "Failed to delete pod '{{ metadata.name }}'" + refresh: true + - key: "e" + name: "Describe" + description: "Describe pod" + page: pod_describe + + # ============================================================================ + # POD DETAIL - Show detailed information about a pod + # ============================================================================ + pod_detail: + title: "Pod - {{ pods.metadata.name }}" + description: "Detailed pod information" + data: + adapter: cli + command: "kubectl" + args: + [ + "get", + "pod", + "{{ pods.metadata.name }}", + "-n", + "{{ namespaces.metadata.name }}", + "-o", + "yaml", + ] + items: "@this" + timeout: "30s" + view: + type: text + syntax: yaml + line_numbers: true + actions: + - key: "l" + name: "Logs" + description: "View logs" + page: pod_logs + - key: "y" + name: "YAML" + description: "View raw YAML" + builtin: yaml_view + + # ============================================================================ + # POD LOGS - Stream pod logs + # ============================================================================ + pod_logs: + title: "Logs - {{ pods.metadata.name }}" + description: "Pod logs (streaming)" + data: + type: stream + command: "kubectl" + args: + [ + "logs", + "-f", + "{{ pods.metadata.name }}", + "-n", + "{{ namespaces.metadata.name }}", + "--tail=100", + ] + buffer_size: 100 + buffer_time: "15m" + follow: true + view: + type: logs + follow: true + wrap: true + show_timestamps: false + + # ============================================================================ + # POD DESCRIBE - kubectl describe output + # ============================================================================ + pod_describe: + title: "Describe - {{ pods.metadata.name }}" + description: "kubectl describe output" + data: + adapter: cli + command: "kubectl" + args: + [ + "describe", + "pod", + "{{ pods.metadata.name }}", + "-n", + "{{ namespaces.metadata.name }}", + ] + items: "@this" + timeout: "30s" + view: + type: text + line_numbers: true + + # ============================================================================ + # DEPLOYMENTS - List deployments in namespace + # ============================================================================ + deployments: + title: "Deployments - {{ namespaces.metadata.name }}" + description: "List all deployments" + data: + adapter: cli + command: "kubectl" + args: + [ + "get", + "deployments", + "-n", + "{{ namespaces.metadata.name }}", + "-o", + "json", + ] + items: "$.items[*]" + timeout: "30s" + refresh_interval: "10s" + view: + type: table + columns: + - path: "$.metadata.name" + display: "Name" + width: 35 + style: + - default: true + color: cyan + bold: true + - path: "$.spec.replicas" + display: "Desired" + width: 10 + style: + - default: true + color: blue + - path: "$.status.availableReplicas" + display: "Available" + width: 12 + style: + - condition: "{{ value == row.spec.replicas }}" + color: green + bold: true + - condition: "{{ value > 0 }}" + color: yellow + - default: true + color: red + - path: "$.status.readyReplicas" + display: "Ready" + width: 10 + style: + - condition: "{{ value == row.spec.replicas }}" + color: green + - default: true + color: yellow + - path: "$.status.updatedReplicas" + display: "Updated" + width: 10 + style: + - condition: "{{ value == row.spec.replicas }}" + color: green + - default: true + color: cyan + - path: "$.metadata.creationTimestamp" + display: "Age" + transform: "{{ value | timeago }}" + width: 15 + style: + - default: true + color: gray + row_style: + - condition: "{{ status.availableReplicas != spec.replicas }}" + color: yellow + next: + page: deployment_detail + context: + deployment_name: "$.metadata.name" + actions: + - key: "s" + name: "Scale" + description: "Scale deployment" + command: "kubectl" + args: + [ + "scale", + "deployment", + "{{ metadata.name }}", + "-n", + "{{ namespaces.metadata.name }}", + "--replicas=3", + ] + confirm: "Scale {{ metadata.name }} to 3 replicas?" + success_message: "Scaled {{ metadata.name }}" + refresh: true + - key: "r" + name: "Restart" + description: "Restart deployment" + command: "kubectl" + args: + [ + "rollout", + "restart", + "deployment", + "{{ metadata.name }}", + "-n", + "{{ namespaces.metadata.name }}", + ] + confirm: "Restart {{ metadata.name }}?" + success_message: "Restarted {{ metadata.name }}" + refresh: true + - key: "d" + name: "Delete" + description: "Delete deployment" + confirm: "Delete deployment {{ metadata.name }}?" + command: "kubectl" + args: + [ + "delete", + "deployment", + "{{ metadata.name }}", + "-n", + "{{ namespaces.metadata.name }}", + ] + success_message: "Deleted {{ metadata.name }}" + error_message: "Failed to delete" + refresh: true + + # ============================================================================ + # DEPLOYMENT DETAIL - Show deployment details + # ============================================================================ + deployment_detail: + title: "Deployment - {{ deployments.metadata.name }}" + description: "Deployment details" + data: + adapter: cli + command: "kubectl" + args: + [ + "get", + "deployment", + "{{ deployments.metadata.name }}", + "-n", + "{{ namespaces.metadata.name }}", + "-o", + "json", + ] + items: "@this" + timeout: "30s" + view: + type: text + syntax: json + line_numbers: true + actions: + - key: "p" + name: "Pods" + description: "View pods for this deployment" + page: deployment_pods + + # ============================================================================ + # DEPLOYMENT PODS - Show pods for a deployment + # ============================================================================ + deployment_pods: + title: "Pods - {{ deployments.metadata.name }}" + description: "Pods managed by deployment" + data: + adapter: cli + command: "kubectl" + args: + [ + "get", + "pods", + "-n", + "{{ namespaces.metadata.name }}", + "-l", + "app={{ deployments.metadata.labels.app }}", + "-o", + "json", + ] + items: "$.items[*]" + timeout: "30s" + view: + type: table + columns: + - path: "$.metadata.name" + display: "Name" + width: 50 + style: + - default: true + color: cyan + - path: "$.status.phase" + display: "Status" + width: 12 + style: + - condition: "{{ value == 'Running' }}" + color: green + bold: true + - condition: "{{ value == 'Pending' }}" + color: yellow + - default: true + color: red + - path: "$.metadata.creationTimestamp" + display: "Age" + transform: "{{ value | timeago }}" + width: 15 + style: + - default: true + color: gray + next: + page: pod_detail + context: + pod_name: "$.metadata.name" + + # ============================================================================ + # SERVICES - List services in namespace + # ============================================================================ + services: + title: "Services - {{ namespaces.metadata.name }}" + description: "List all services" + data: + adapter: cli + command: "kubectl" + args: + [ + "get", + "services", + "-n", + "{{ namespaces.metadata.name }}", + "-o", + "json", + ] + items: "$.items[*]" + timeout: "30s" + view: + type: table + columns: + - path: "$.metadata.name" + display: "Name" + width: 30 + style: + - default: true + color: cyan + bold: true + - path: "$.spec.type" + display: "Type" + width: 15 + style: + - condition: "{{ value == 'LoadBalancer' }}" + color: magenta + bold: true + - condition: "{{ value == 'NodePort' }}" + color: yellow + - condition: "{{ value == 'ClusterIP' }}" + color: blue + - default: true + color: white + - path: "$.spec.clusterIP" + display: "Cluster IP" + width: 18 + style: + - default: true + color: green + - path: "$.spec.ports[*].port" + display: "Ports" + width: 20 + style: + - default: true + color: blue + - path: "$.metadata.creationTimestamp" + display: "Age" + transform: "{{ value | timeago }}" + width: 15 + style: + - default: true + color: gray + next: + page: service_detail + context: + service_name: "$.metadata.name" + actions: + - key: "d" + name: "Delete" + description: "Delete service" + confirm: "Delete service {{ metadata.name }}?" + command: "kubectl" + args: + [ + "delete", + "service", + "{{ metadata.name }}", + "-n", + "{{ namespaces.metadata.name }}", + ] + success_message: "Deleted service {{ metadata.name }}" + refresh: true + + # ============================================================================ + # SERVICE DETAIL - Show service details + # ============================================================================ + service_detail: + title: "Service - {{ services.metadata.name }}" + description: "Service details" + data: + adapter: cli + command: "kubectl" + args: + [ + "get", + "service", + "{{ services.metadata.name }}", + "-n", + "{{ namespaces.metadata.name }}", + "-o", + "yaml", + ] + items: "@this" + timeout: "30s" + view: + type: text + syntax: yaml + line_numbers: true + + # ============================================================================ + # RBAC OVERVIEW - Show RBAC resources + # ============================================================================ + rbac_overview: + title: "RBAC - {{ namespaces.metadata.name }}" + description: "Role-Based Access Control resources" + data: + adapter: cli + command: "kubectl" + args: + [ + "get", + "roles,rolebindings", + "-n", + "{{ namespaces.metadata.name }}", + "-o", + "json", + ] + items: "$.items[*]" + timeout: "30s" + view: + type: table + columns: + - path: "$.kind" + display: "Kind" + width: 15 + style: + - condition: "{{ value == 'Role' }}" + color: yellow + bold: true + - condition: "{{ value == 'RoleBinding' }}" + color: magenta + bold: true + - default: true + color: cyan + - path: "$.metadata.name" + display: "Name" + width: 40 + style: + - default: true + color: cyan + - path: "$.metadata.creationTimestamp" + display: "Age" + transform: "{{ value | timeago }}" + width: 20 + style: + - default: true + color: gray + next: + page: rbac_detail + context: + rbac_kind: "$.kind" + rbac_name: "$.metadata.name" + actions: + - key: "r" + name: "Roles" + description: "View roles only" + page: roles + context: + namespace: "{{ namespaces.metadata.name }}" + - key: "b" + name: "RoleBindings" + description: "View role bindings only" + page: rolebindings + context: + namespace: "{{ namespaces.metadata.name }}" + - key: "c" + name: "ClusterRoles" + description: "View cluster-wide roles" + page: clusterroles + + # ============================================================================ + # ROLES - List roles in namespace + # ============================================================================ + roles: + title: "Roles - {{ namespaces.metadata.name }}" + description: "Namespace-scoped roles" + data: + adapter: cli + command: "kubectl" + args: + ["get", "roles", "-n", "{{ namespaces.metadata.name }}", "-o", "json"] + items: "$.items[*]" + timeout: "30s" + view: + type: table + columns: + - path: "$.metadata.name" + display: "Name" + width: 50 + style: + - default: true + color: yellow + bold: true + - path: "$.rules[*]" + display: "Rules" + transform: "{{ value | length }}" + width: 10 + style: + - default: true + color: blue + - path: "$.metadata.creationTimestamp" + display: "Age" + transform: "{{ value | timeago }}" + width: 20 + style: + - default: true + color: gray + next: + page: rbac_detail + context: + rbac_kind: "Role" + rbac_name: "$.metadata.name" + + # ============================================================================ + # ROLEBINDINGS - List role bindings in namespace + # ============================================================================ + rolebindings: + title: "RoleBindings - {{ namespaces.metadata.name }}" + description: "Namespace-scoped role bindings" + data: + adapter: cli + command: "kubectl" + args: + [ + "get", + "rolebindings", + "-n", + "{{ namespaces.metadata.name }}", + "-o", + "json", + ] + items: "$.items[*]" + timeout: "30s" + view: + type: table + columns: + - path: "$.metadata.name" + display: "Name" + width: 40 + style: + - default: true + color: magenta + bold: true + - path: "$.roleRef.name" + display: "Role" + width: 30 + style: + - default: true + color: yellow + - path: "$.subjects[*]" + display: "Subjects" + transform: "{{ value | length }}" + width: 10 + style: + - default: true + color: blue + - path: "$.metadata.creationTimestamp" + display: "Age" + transform: "{{ value | timeago }}" + width: 20 + style: + - default: true + color: gray + next: + page: rbac_detail + context: + rbac_kind: "RoleBinding" + rbac_name: "$.metadata.name" + + # ============================================================================ + # CLUSTERROLES - List cluster roles + # ============================================================================ + clusterroles: + title: "ClusterRoles" + description: "Cluster-wide roles" + data: + adapter: cli + command: "kubectl" + args: ["get", "clusterroles", "-o", "json"] + items: "$.items[*]" + timeout: "30s" + view: + type: table + columns: + - path: "$.metadata.name" + display: "Name" + width: 50 + style: + - condition: "{{ value | startswith('system:') }}" + color: gray + - default: true + color: yellow + bold: true + - path: "$.rules[*]" + display: "Rules" + transform: "{{ value | length }}" + width: 10 + style: + - default: true + color: blue + - path: "$.metadata.creationTimestamp" + display: "Age" + transform: "{{ value | timeago }}" + width: 20 + style: + - default: true + color: gray + next: + page: clusterrole_detail + context: + clusterrole_name: "$.metadata.name" + + # ============================================================================ + # RBAC DETAIL - Show detailed RBAC resource information + # ============================================================================ + rbac_detail: + title: "{{ rbac_overview.kind }} - {{ rbac_overview.metadata.name }}" + description: "RBAC resource details" + data: + adapter: cli + command: "kubectl" + args: + [ + "get", + "{{ rbac_overview.kind }}", + "{{ rbac_overview.metadata.name }}", + "-n", + "{{ namespaces.metadata.name }}", + "-o", + "yaml", + ] + items: "@this" + timeout: "30s" + view: + type: text + syntax: yaml + line_numbers: true + + # ============================================================================ + # CLUSTERROLE DETAIL - Show cluster role details + # ============================================================================ + clusterrole_detail: + title: "ClusterRole - {{ clusterroles.metadata.name }}" + description: "Cluster role details" + data: + adapter: cli + command: "kubectl" + args: + ["get", "clusterrole", "{{ clusterroles.metadata.name }}", "-o", "yaml"] + items: "@this" + timeout: "30s" + view: + type: text + syntax: yaml + line_numbers: true diff --git a/examples/stream-test.yaml b/examples/stream-test.yaml new file mode 100644 index 0000000..ee63002 --- /dev/null +++ b/examples/stream-test.yaml @@ -0,0 +1,44 @@ +# ============================================================================= +# Stream Test Example +# ============================================================================= +# Demonstrates real-time streaming capabilities with the stream adapter. +# +# Usage: cargo run -- examples/stream-test.yaml +# +# This example shows: +# - Stream adapter for real-time data +# - Logs view with follow mode +# - Buffer configuration for streaming data +# - Live ping output display +# +# Pro tip: Watch those packets fly! It's like a heartbeat monitor for your +# network connection, but less stressful (hopefully). +# ============================================================================= + +version: v1 + +app: + name: "Stream Test" + description: "Test streaming functionality" + +start: ping_stream + +pages: + # -------------------------------------------------------------------------- + # Ping Stream - Real-time ping output + # -------------------------------------------------------------------------- + # Streams ping output directly to the terminal in real-time. + # Great for monitoring network connectivity or just watching numbers go by. + ping_stream: + title: "Ping Test - Streaming" + description: "Stream ping output in real-time" + data: + type: stream + command: "ping" + args: ["-c", "50", "8.8.8.8"] # Ping Google DNS 50 times + buffer_size: 20 # Keep last 20 lines in memory + follow: true # Auto-scroll to new content + view: + type: logs + follow: false + wrap: true diff --git a/examples/style-test.yaml b/examples/style-test.yaml new file mode 100644 index 0000000..9f2d852 --- /dev/null +++ b/examples/style-test.yaml @@ -0,0 +1,87 @@ +# ============================================================================= +# Style Test Example +# ============================================================================= +# Demonstrates TermStack's styling capabilities for terminal UI. +# +# Usage: cargo run -- examples/style-test.yaml +# +# This example shows: +# - Conditional column styling based on values +# - Row-level styling for entire rows +# - Color options (green, red, yellow, cyan, blue, etc.) +# - Text modifiers (bold, dim) +# - Default fallback styles +# +# Because life's too short for boring monochrome terminals! +# ============================================================================= + +version: v1 + +app: + name: "Style Test" + +start: items + +pages: + # -------------------------------------------------------------------------- + # Styled Items - Showcasing all styling features + # -------------------------------------------------------------------------- + # This page demonstrates how to make your terminal look fabulous. + # Uses inline JSON data to show different status styles. + items: + title: "Styled Items Test" + data: + adapter: cli + command: "echo" + args: + [ + '{"items": [{"name": "Running Pod", "status": "Running", "count": 5}, {"name": "Failed Pod", "status": "Failed", "count": 0}, {"name": "Pending Pod", "status": "Pending", "count": 3}]}', + ] + items: "$.items[*]" + view: + type: table + columns: + # Name column - Always cyan and bold + - path: "$.name" + display: "Name" + width: 30 + style: + - default: true + color: cyan + bold: true + + # Status column - Traffic light styling + # Green = good, Yellow = meh, Red = uh oh + - path: "$.status" + display: "Status" + width: 15 + style: + - condition: "{{ value == 'Running' }}" + color: green + bold: true + - condition: "{{ value == 'Failed' }}" + color: red + bold: true + - condition: "{{ value == 'Pending' }}" + color: yellow + - default: true + color: white + + # Count column - Zero is bad, anything else is fine + - path: "$.count" + display: "Count" + width: 10 + style: + - condition: "{{ value == '0' }}" + color: red + - default: true + color: blue + + # Row-level styling - Dims failed/pending rows + # So your eyes go straight to the healthy stuff + row_style: + - condition: "{{ status == 'Failed' }}" + color: red + dim: true + - condition: "{{ status == 'Pending' }}" + dim: true diff --git a/src/action/builtins.rs b/src/action/builtins.rs new file mode 100644 index 0000000..dea6bb5 --- /dev/null +++ b/src/action/builtins.rs @@ -0,0 +1,2 @@ +/// Built-in actions (to be implemented) +pub struct BuiltinActions; diff --git a/src/action/executor.rs b/src/action/executor.rs new file mode 100644 index 0000000..abf4e88 --- /dev/null +++ b/src/action/executor.rs @@ -0,0 +1,238 @@ +use crate::config::schema::{Action, HttpAction, HttpMethod}; +use crate::error::{Result, TermStackError}; +use crate::template::engine::{TemplateContext, TemplateEngine}; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::Arc; + +#[derive(Debug, Clone)] +pub enum ActionResult { + Success(Option), + Error(String), + Refresh, + Navigate(String, std::collections::HashMap), +} + +pub struct ActionExecutor { + template_engine: Arc, + http_client: reqwest::Client, +} + +impl ActionExecutor { + pub fn new(template_engine: Arc) -> Self { + Self { + template_engine, + http_client: reqwest::Client::new(), + } + } + + pub async fn execute( + &self, + action: &Action, + context: &HashMap, + ) -> Result { + // Convert HashMap to TemplateContext + let template_ctx = Self::hashmap_to_context(context); + + // Page navigation action + if let Some(page) = &action.page { + if !page.is_empty() { + // Page navigation is handled by the app itself + return Ok(ActionResult::Navigate(page.clone(), action.context.clone())); + } + } + + // CLI action (check for non-empty string) + if let Some(command) = &action.command { + if !command.is_empty() { + return self.execute_cli(action, command, &template_ctx).await; + } + } + + // HTTP action + if let Some(http) = &action.http { + return self.execute_http(action, http, &template_ctx).await; + } + + // TODO: Script action + if let Some(script) = &action.script { + if !script.is_empty() { + return Err(TermStackError::Config( + "Script actions not yet implemented".to_string(), + )); + } + } + + // TODO: Builtin action + if let Some(builtin) = &action.builtin { + if !builtin.is_empty() { + return Err(TermStackError::Config( + "Builtin actions not yet implemented".to_string(), + )); + } + } + + Err(TermStackError::Config(format!( + "Action '{}' must have command, http, script, builtin, or page specified", + action.name + ))) + } + + fn hashmap_to_context(map: &HashMap) -> TemplateContext { + let mut ctx = TemplateContext::new(); + + // Try to extract globals, page contexts, and current row if they exist + for (key, value) in map { + if key == "row" || key == "value" { + if let Some(v) = value.as_object() { + ctx = ctx.with_current(Value::Object(v.clone())); + } + } else { + ctx.page_contexts.insert(key.clone(), value.clone()); + } + } + + ctx + } + + async fn execute_cli( + &self, + action: &Action, + command: &str, + context: &TemplateContext, + ) -> Result { + // Render command with template + let rendered_command = self + .template_engine + .render_string(command, context) + .map_err(|e| TermStackError::Template(e.to_string()))?; + + // Render args with template + let mut rendered_args = Vec::new(); + for arg in &action.args { + let rendered_arg = self + .template_engine + .render_string(arg, context) + .map_err(|e| TermStackError::Template(e.to_string()))?; + rendered_args.push(rendered_arg); + } + + // Execute command + let output = tokio::process::Command::new(&rendered_command) + .args(&rendered_args) + .output() + .await + .map_err(|e| TermStackError::Io(e))?; + + if output.status.success() { + let message = if let Some(msg) = &action.success_message { + Some( + self.template_engine + .render_string(msg, context) + .map_err(|e| TermStackError::Template(e.to_string()))?, + ) + } else { + Some(format!("Command executed successfully")) + }; + + if action.refresh { + Ok(ActionResult::Refresh) + } else { + Ok(ActionResult::Success(message)) + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let message = if let Some(msg) = &action.error_message { + self.template_engine + .render_string(msg, context) + .map_err(|e| TermStackError::Template(e.to_string()))? + } else { + format!("Command failed: {}", stderr) + }; + Ok(ActionResult::Error(message)) + } + } + + async fn execute_http( + &self, + action: &Action, + http: &HttpAction, + context: &TemplateContext, + ) -> Result { + // Render URL with template + let rendered_url = self + .template_engine + .render_string(&http.url, context) + .map_err(|e| TermStackError::Template(e.to_string()))?; + + // Build request + let mut request = match http.method { + HttpMethod::GET => self.http_client.get(&rendered_url), + HttpMethod::POST => self.http_client.post(&rendered_url), + HttpMethod::PUT => self.http_client.put(&rendered_url), + HttpMethod::DELETE => self.http_client.delete(&rendered_url), + HttpMethod::PATCH => self.http_client.patch(&rendered_url), + }; + + // Add headers + for (key, value) in &http.headers { + let rendered_value = self + .template_engine + .render_string(value, context) + .map_err(|e| TermStackError::Template(e.to_string()))?; + request = request.header(key, rendered_value); + } + + // Add body if present + if let Some(body) = &http.body { + let rendered_body = self + .template_engine + .render_string(body, context) + .map_err(|e| TermStackError::Template(e.to_string()))?; + request = request.body(rendered_body); + } + + // Execute request + let response = request.send().await.map_err(|e| TermStackError::Http(e))?; + + if response.status().is_success() { + let message = if let Some(msg) = &action.success_message { + Some( + self.template_engine + .render_string(msg, context) + .map_err(|e| TermStackError::Template(e.to_string()))?, + ) + } else { + Some(format!("HTTP request succeeded")) + }; + + if action.refresh { + Ok(ActionResult::Refresh) + } else { + Ok(ActionResult::Success(message)) + } + } else { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "Failed to read response body".to_string()); + let message = if let Some(msg) = &action.error_message { + self.template_engine + .render_string(msg, context) + .map_err(|e| TermStackError::Template(e.to_string()))? + } else { + format!("HTTP request failed: {} - {}", status, body) + }; + Ok(ActionResult::Error(message)) + } + } +} + +impl Default for ActionExecutor { + fn default() -> Self { + Self::new(Arc::new( + TemplateEngine::new().expect("Failed to create template engine"), + )) + } +} diff --git a/src/action/mod.rs b/src/action/mod.rs new file mode 100644 index 0000000..cff6460 --- /dev/null +++ b/src/action/mod.rs @@ -0,0 +1,4 @@ +pub mod builtins; +pub mod executor; + +pub use executor::ActionExecutor; diff --git a/src/adapters/cli.rs b/src/adapters/cli.rs new file mode 100644 index 0000000..7d50e31 --- /dev/null +++ b/src/adapters/cli.rs @@ -0,0 +1,246 @@ +use anyhow::{Result, anyhow}; +use async_trait::async_trait; +use serde_json::Value; +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; +use tokio::process::Command; + +use super::DataSourceAdapter; +use crate::config::schema::SingleDataSource; +use crate::data::provider::DataContext; +use crate::template::engine::{TemplateContext, TemplateEngine}; + +/// CLI command data adapter +pub struct CliAdapter; + +impl CliAdapter { + pub fn new() -> Self { + Self + } + + /// Extract CLI configuration from data source + fn extract_config(source: &SingleDataSource) -> Result { + let command = source + .config + .get("command") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("Missing 'command' field for CLI adapter"))? + .to_string(); + + let args = source + .config + .get("args") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect() + }) + .unwrap_or_default(); + + let shell = source + .config + .get("shell") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let working_dir = source + .config + .get("working_dir") + .and_then(|v| v.as_str()) + .map(PathBuf::from); + + let env = source + .config + .get("env") + .and_then(|v| v.as_object()) + .map(|obj| { + obj.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default(); + + let timeout = source.timeout.as_deref().unwrap_or("30s"); + let timeout_duration = parse_duration(timeout)?; + + Ok(CliConfig { + command, + args, + shell, + working_dir, + env, + timeout: timeout_duration, + }) + } + + /// Convert DataContext to TemplateContext + fn to_template_context(ctx: &DataContext) -> TemplateContext { + let mut template_ctx = TemplateContext::new().with_globals(ctx.globals.clone()); + + // Add each page context individually + for (page, data) in &ctx.page_contexts { + template_ctx = template_ctx.with_page_context(page.clone(), data.clone()); + } + + template_ctx + } +} + +#[async_trait] +impl DataSourceAdapter for CliAdapter { + fn name(&self) -> &str { + "cli" + } + + async fn fetch(&self, source: &SingleDataSource, ctx: &DataContext) -> Result { + let config = Self::extract_config(source)?; + let template_engine = TemplateEngine::new()?; + let template_ctx = Self::to_template_context(ctx); + + // Render templates in args + let rendered_args: Vec = config + .args + .iter() + .map(|arg| { + if TemplateEngine::is_template(arg) { + template_engine + .render_string(arg, &template_ctx) + .map_err(|e| anyhow!("{}", e)) + } else { + Ok(arg.clone()) + } + }) + .collect::>>()?; + + // Execute command + let output = if config.shell { + // Run in shell + let shell_cmd = if cfg!(target_os = "windows") { + "cmd" + } else { + "sh" + }; + + let shell_arg = if cfg!(target_os = "windows") { + "/C" + } else { + "-c" + }; + + let full_command = format!("{} {}", config.command, rendered_args.join(" ")); + + let mut cmd = Command::new(shell_cmd); + cmd.arg(shell_arg).arg(full_command); + + if let Some(dir) = &config.working_dir { + cmd.current_dir(dir); + } + + for (key, value) in &config.env { + cmd.env(key, value); + } + + tokio::time::timeout(config.timeout, cmd.output()) + .await + .map_err(|_| anyhow!("Command timed out after {:?}", config.timeout))?? + } else { + // Direct execution + let mut cmd = Command::new(&config.command); + cmd.args(&rendered_args); + + if let Some(dir) = &config.working_dir { + cmd.current_dir(dir); + } + + for (key, value) in &config.env { + cmd.env(key, value); + } + + tokio::time::timeout(config.timeout, cmd.output()) + .await + .map_err(|_| anyhow!("Command timed out after {:?}", config.timeout))?? + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!( + "Command failed with status {}: {}", + output.status, + stderr + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Try to parse as JSON first + match serde_json::from_str(&stdout) { + Ok(json) => Ok(json), + Err(_) => { + // If JSON parsing fails, wrap the raw text as a JSON string value + Ok(Value::String(stdout.to_string())) + } + } + } +} + +/// CLI configuration extracted from data source +struct CliConfig { + command: String, + args: Vec, + shell: bool, + working_dir: Option, + env: HashMap, + timeout: Duration, +} + +/// Parse duration string (e.g., "30s", "5m", "1h") +fn parse_duration(s: &str) -> Result { + let s = s.trim(); + if s.is_empty() { + return Err(anyhow!("Empty duration string")); + } + + let (num_str, unit) = if s.ends_with("ms") { + (&s[..s.len() - 2], "ms") + } else if s.ends_with('s') { + (&s[..s.len() - 1], "s") + } else if s.ends_with('m') { + (&s[..s.len() - 1], "m") + } else if s.ends_with('h') { + (&s[..s.len() - 1], "h") + } else { + // Default to seconds if no unit + (s, "s") + }; + + let num: u64 = num_str + .parse() + .map_err(|_| anyhow!("Invalid duration number: {}", num_str))?; + + let duration = match unit { + "ms" => Duration::from_millis(num), + "s" => Duration::from_secs(num), + "m" => Duration::from_secs(num * 60), + "h" => Duration::from_secs(num * 3600), + _ => return Err(anyhow!("Invalid duration unit: {}", unit)), + }; + + Ok(duration) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_duration() { + assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30)); + assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300)); + assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600)); + assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500)); + assert_eq!(parse_duration("10").unwrap(), Duration::from_secs(10)); + } +} diff --git a/src/adapters/http.rs b/src/adapters/http.rs new file mode 100644 index 0000000..9f84f34 --- /dev/null +++ b/src/adapters/http.rs @@ -0,0 +1,263 @@ +use anyhow::{Result, anyhow}; +use async_trait::async_trait; +use reqwest::Method; +use serde_json::Value; +use std::collections::HashMap; +use std::time::Duration; + +use super::DataSourceAdapter; +use crate::config::schema::{HttpMethod, SingleDataSource}; +use crate::data::provider::DataContext; +use crate::globals; +use crate::template::engine::{TemplateContext, TemplateEngine}; + +/// HTTP data adapter +pub struct HttpAdapter; + +impl HttpAdapter { + pub fn new() -> Self { + Self + } + + /// Extract HTTP configuration from data source + fn extract_config(source: &SingleDataSource) -> Result { + let url = source + .config + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("Missing 'url' field for HTTP adapter"))? + .to_string(); + + let method = source + .config + .get("method") + .and_then(|v| v.as_str()) + .and_then(|s| match s.to_uppercase().as_str() { + "GET" => Some(HttpMethod::GET), + "POST" => Some(HttpMethod::POST), + "PUT" => Some(HttpMethod::PUT), + "DELETE" => Some(HttpMethod::DELETE), + "PATCH" => Some(HttpMethod::PATCH), + _ => None, + }) + .unwrap_or(HttpMethod::GET); + + let headers = source + .config + .get("headers") + .and_then(|v| v.as_object()) + .map(|obj| { + obj.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default(); + + let params = source + .config + .get("params") + .and_then(|v| v.as_object()) + .map(|obj| { + obj.iter() + .filter_map(|(k, v)| { + // Handle both string and number values + let val = match v { + Value::String(s) => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + Value::Bool(b) => Some(b.to_string()), + _ => v.as_str().map(String::from), + }; + val.map(|s| (k.clone(), s)) + }) + .collect() + }) + .unwrap_or_default(); + + let body = source + .config + .get("body") + .and_then(|v| v.as_str()) + .map(String::from); + + let timeout = source.timeout.as_deref().unwrap_or("30s"); + let timeout_duration = parse_duration(timeout)?; + + Ok(HttpConfig { + url, + method, + headers, + params, + body, + timeout: timeout_duration, + }) + } + + /// Convert DataContext to TemplateContext + fn to_template_context(ctx: &DataContext) -> TemplateContext { + let mut template_ctx = TemplateContext::new().with_globals(ctx.globals.clone()); + + // Add each page context individually + for (page, data) in &ctx.page_contexts { + template_ctx = template_ctx.with_page_context(page.clone(), data.clone()); + } + + template_ctx + } +} + +#[async_trait] +impl DataSourceAdapter for HttpAdapter { + fn name(&self) -> &str { + "http" + } + + async fn fetch(&self, source: &SingleDataSource, ctx: &DataContext) -> Result { + let config = Self::extract_config(source)?; + let template_engine = TemplateEngine::new()?; + let template_ctx = Self::to_template_context(ctx); + + // Render URL template + let url = if TemplateEngine::is_template(&config.url) { + template_engine.render_string(&config.url, &template_ctx)? + } else { + config.url.clone() + }; + + // Get HTTP client + let client = globals::http_client(); + + // Convert HttpMethod to reqwest::Method + let method = match config.method { + HttpMethod::GET => Method::GET, + HttpMethod::POST => Method::POST, + HttpMethod::PUT => Method::PUT, + HttpMethod::DELETE => Method::DELETE, + HttpMethod::PATCH => Method::PATCH, + }; + + let mut request = client.request(method, &url); + + // Add headers (with template rendering) + for (key, value) in &config.headers { + let rendered_value = if TemplateEngine::is_template(value) { + template_engine.render_string(value, &template_ctx)? + } else { + value.clone() + }; + request = request.header(key, rendered_value); + } + + // Add query params (with template rendering) + if !config.params.is_empty() { + let rendered_params: Vec<(String, String)> = config + .params + .iter() + .map(|(k, v)| { + let rendered_value = if TemplateEngine::is_template(v) { + template_engine + .render_string(v, &template_ctx) + .map_err(|e| anyhow!("{}", e)) + } else { + Ok(v.clone()) + }; + rendered_value.map(|val| (k.clone(), val)) + }) + .collect::>>()?; + + request = request.query(&rendered_params); + } + + // Add body (with template rendering) + if let Some(body) = &config.body { + let rendered_body = if TemplateEngine::is_template(body) { + template_engine.render_string(body, &template_ctx)? + } else { + body.clone() + }; + request = request.body(rendered_body); + } + + // Set timeout + request = request.timeout(config.timeout); + + // Execute request + let response = request + .send() + .await + .map_err(|e| anyhow!("HTTP request failed: {}", e))?; + + if !response.status().is_success() { + return Err(anyhow!( + "HTTP request failed with status: {}", + response.status() + )); + } + + let text = response + .text() + .await + .map_err(|e| anyhow!("Failed to read response body: {}", e))?; + + // Parse as JSON + serde_json::from_str(&text).map_err(|e| anyhow!("Failed to parse response as JSON: {}", e)) + } +} + +/// HTTP configuration extracted from data source +struct HttpConfig { + url: String, + method: HttpMethod, + headers: HashMap, + params: HashMap, + body: Option, + timeout: Duration, +} + +/// Parse duration string (e.g., "30s", "5m", "1h") +fn parse_duration(s: &str) -> Result { + let s = s.trim(); + if s.is_empty() { + return Err(anyhow!("Empty duration string")); + } + + let (num_str, unit) = if s.ends_with("ms") { + (&s[..s.len() - 2], "ms") + } else if s.ends_with('s') { + (&s[..s.len() - 1], "s") + } else if s.ends_with('m') { + (&s[..s.len() - 1], "m") + } else if s.ends_with('h') { + (&s[..s.len() - 1], "h") + } else { + // Default to seconds if no unit + (s, "s") + }; + + let num: u64 = num_str + .parse() + .map_err(|_| anyhow!("Invalid duration number: {}", num_str))?; + + let duration = match unit { + "ms" => Duration::from_millis(num), + "s" => Duration::from_secs(num), + "m" => Duration::from_secs(num * 60), + "h" => Duration::from_secs(num * 3600), + _ => return Err(anyhow!("Invalid duration unit: {}", unit)), + }; + + Ok(duration) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_duration() { + assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30)); + assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300)); + assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600)); + assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500)); + assert_eq!(parse_duration("10").unwrap(), Duration::from_secs(10)); + } +} diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs new file mode 100644 index 0000000..2d91ce6 --- /dev/null +++ b/src/adapters/mod.rs @@ -0,0 +1,30 @@ +use crate::config::schema::SingleDataSource; +use crate::data::provider::DataContext; +use anyhow::Result; +use async_trait::async_trait; +use serde_json::Value; + +pub mod cli; +pub mod http; +pub mod registry; +pub mod script; + +/// Trait for data source adapters +/// +/// Adapters are responsible for fetching data from various sources (CLI, HTTP, databases, etc.) +/// and returning it as JSON Value that can be processed by the rest of the application. +#[async_trait] +pub trait DataSourceAdapter: Send + Sync { + /// Returns the unique name of this adapter (e.g., "cli", "http", "script", "postgres") + fn name(&self) -> &str; + + /// Fetches data from the source + /// + /// # Arguments + /// * `source` - The data source configuration + /// * `ctx` - The data context containing globals and page contexts for template rendering + /// + /// # Returns + /// A JSON Value containing the fetched data + async fn fetch(&self, source: &SingleDataSource, ctx: &DataContext) -> Result; +} diff --git a/src/adapters/registry.rs b/src/adapters/registry.rs new file mode 100644 index 0000000..fd79513 --- /dev/null +++ b/src/adapters/registry.rs @@ -0,0 +1,89 @@ +use super::DataSourceAdapter; +use super::cli::CliAdapter; +use super::http::HttpAdapter; +use super::script::ScriptAdapter; +use crate::config::schema::SingleDataSource; +use crate::data::provider::DataContext; +use anyhow::{Result, anyhow}; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::Arc; + +/// Registry for data source adapters +/// +/// The registry manages all available adapters and routes fetch requests +/// to the appropriate adapter based on the data source configuration. +pub struct AdapterRegistry { + adapters: HashMap>, +} + +impl AdapterRegistry { + /// Creates a new empty adapter registry + pub fn new() -> Self { + Self { + adapters: HashMap::new(), + } + } + + /// Creates a registry with default built-in adapters registered + pub fn with_defaults() -> Self { + let mut registry = Self::new(); + + // Register built-in adapters + registry.register(Arc::new(CliAdapter::new())); + registry.register(Arc::new(HttpAdapter::new())); + registry.register(Arc::new(ScriptAdapter::new())); + + registry + } + + /// Registers a new adapter + /// + /// # Arguments + /// * `adapter` - The adapter to register + pub fn register(&mut self, adapter: Arc) { + self.adapters.insert(adapter.name().to_string(), adapter); + } + + /// Fetches data using the appropriate adapter + /// + /// # Arguments + /// * `source` - The data source configuration + /// * `ctx` - The data context for template rendering + /// + /// # Returns + /// The fetched data as a JSON Value + /// + /// # Errors + /// Returns an error if: + /// - No adapter is specified in the data source + /// - The specified adapter is not registered + /// - The adapter fails to fetch data + pub async fn fetch(&self, source: &SingleDataSource, ctx: &DataContext) -> Result { + let adapter_name = source + .get_adapter_name() + .ok_or_else(|| anyhow!("No adapter specified in data source"))?; + + let adapter = self.adapters.get(&adapter_name).ok_or_else(|| { + let available: Vec = self.adapters.keys().cloned().collect(); + anyhow!( + "Unknown adapter: '{}'. Available adapters: {}", + adapter_name, + available.join(", ") + ) + })?; + + adapter.fetch(source, ctx).await + } + + /// Returns the list of registered adapter names + pub fn list_adapters(&self) -> Vec { + self.adapters.keys().cloned().collect() + } +} + +impl Default for AdapterRegistry { + fn default() -> Self { + Self::with_defaults() + } +} diff --git a/src/adapters/script.rs b/src/adapters/script.rs new file mode 100644 index 0000000..70da5cd --- /dev/null +++ b/src/adapters/script.rs @@ -0,0 +1,189 @@ +use anyhow::{Result, anyhow}; +use async_trait::async_trait; +use serde_json::Value; +use std::path::Path; +use std::time::Duration; +use tokio::process::Command; + +use super::DataSourceAdapter; +use crate::config::schema::SingleDataSource; +use crate::data::provider::DataContext; +use crate::template::engine::{TemplateContext, TemplateEngine}; + +/// Script data adapter +/// +/// Executes shell scripts that output JSON data. +/// This allows users to integrate custom data sources without writing Rust code. +pub struct ScriptAdapter; + +impl ScriptAdapter { + pub fn new() -> Self { + Self + } + + /// Extract script configuration from data source + fn extract_config(source: &SingleDataSource) -> Result { + let script = source + .config + .get("script") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("Missing 'script' field for script adapter"))? + .to_string(); + + let args = source + .config + .get("args") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect() + }) + .unwrap_or_default(); + + let timeout = source.timeout.as_deref().unwrap_or("30s"); + let timeout_duration = parse_duration(timeout)?; + + Ok(ScriptConfig { + script, + args, + timeout: timeout_duration, + }) + } + + /// Convert DataContext to TemplateContext + fn to_template_context(ctx: &DataContext) -> TemplateContext { + let mut template_ctx = TemplateContext::new().with_globals(ctx.globals.clone()); + + // Add each page context individually + for (page, data) in &ctx.page_contexts { + template_ctx = template_ctx.with_page_context(page.clone(), data.clone()); + } + + template_ctx + } +} + +#[async_trait] +impl DataSourceAdapter for ScriptAdapter { + fn name(&self) -> &str { + "script" + } + + async fn fetch(&self, source: &SingleDataSource, ctx: &DataContext) -> Result { + let config = Self::extract_config(source)?; + let template_engine = TemplateEngine::new()?; + let template_ctx = Self::to_template_context(ctx); + + // Validate script exists + if !Path::new(&config.script).exists() { + return Err(anyhow!("Script not found: {}", config.script)); + } + + // Render template args + let rendered_args: Vec = config + .args + .iter() + .map(|arg| { + if TemplateEngine::is_template(arg) { + template_engine + .render_string(arg, &template_ctx) + .map_err(|e| anyhow!("{}", e)) + } else { + Ok(arg.clone()) + } + }) + .collect::>>()?; + + // Serialize context as JSON for script to use + let context_json = serde_json::to_string(ctx) + .map_err(|e| anyhow!("Failed to serialize context: {}", e))?; + + // Execute script with timeout + let output = tokio::time::timeout( + config.timeout, + Command::new(&config.script) + .args(&rendered_args) + .env("TERMSTACK_CONTEXT", context_json) + .output(), + ) + .await + .map_err(|_| anyhow!("Script timed out after {:?}", config.timeout))??; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!( + "Script failed (exit code {}): {}", + output.status.code().unwrap_or(-1), + stderr + )); + } + + // Parse JSON output + let stdout = String::from_utf8_lossy(&output.stdout); + serde_json::from_str(&stdout).map_err(|e| { + anyhow!( + "Script did not output valid JSON: {}. Output: {}", + e, + stdout + ) + }) + } +} + +/// Script configuration extracted from data source +struct ScriptConfig { + script: String, + args: Vec, + timeout: Duration, +} + +/// Parse duration string (e.g., "30s", "5m", "1h") +fn parse_duration(s: &str) -> Result { + let s = s.trim(); + if s.is_empty() { + return Err(anyhow!("Empty duration string")); + } + + let (num_str, unit) = if s.ends_with("ms") { + (&s[..s.len() - 2], "ms") + } else if s.ends_with('s') { + (&s[..s.len() - 1], "s") + } else if s.ends_with('m') { + (&s[..s.len() - 1], "m") + } else if s.ends_with('h') { + (&s[..s.len() - 1], "h") + } else { + // Default to seconds if no unit + (s, "s") + }; + + let num: u64 = num_str + .parse() + .map_err(|_| anyhow!("Invalid duration number: {}", num_str))?; + + let duration = match unit { + "ms" => Duration::from_millis(num), + "s" => Duration::from_secs(num), + "m" => Duration::from_secs(num * 60), + "h" => Duration::from_secs(num * 3600), + _ => return Err(anyhow!("Invalid duration unit: {}", unit)), + }; + + Ok(duration) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_duration() { + assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30)); + assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300)); + assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600)); + assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500)); + assert_eq!(parse_duration("10").unwrap(), Duration::from_secs(10)); + } +} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..a8e2f4c --- /dev/null +++ b/src/app.rs @@ -0,0 +1,2780 @@ +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; +use ratatui::{ + DefaultTerminal, Frame, + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table}, +}; +use serde_json::Value; +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; +use tokio::sync::mpsc; + +use crate::{ + action::executor::{ActionExecutor, ActionResult}, + config::{Config, View as ConfigView}, + data::{JsonPathExtractor, StreamMessage}, + error::Result, + globals, + navigation::{NavigationContext, NavigationFrame, NavigationStack}, + template::engine::TemplateContext, +}; +use regex::Regex; + +/// Global search state that works across all views +#[derive(Debug, Clone)] +struct GlobalSearch { + /// Whether search input is active + active: bool, + /// The search query string + query: String, + /// Whether the filter is applied (search was confirmed) + filter_active: bool, + /// Compiled regex pattern (cached) + regex_pattern: Option, + /// Whether to use case-sensitive search + case_sensitive: bool, +} + +impl Default for GlobalSearch { + fn default() -> Self { + Self { + active: false, + query: String::new(), + filter_active: false, + regex_pattern: None, + case_sensitive: false, + } + } +} + +impl GlobalSearch { + /// Compile the query into a regex pattern + fn compile_pattern(&mut self) { + if self.query.is_empty() { + self.regex_pattern = None; + return; + } + + // Check if query starts with '!' for regex mode + let pattern_str = if self.query.starts_with('!') { + // Regex mode: use query after '!' + self.query[1..].to_string() + } else { + // Literal mode: escape special regex characters + regex::escape(&self.query) + }; + + // Build regex with case sensitivity + let regex_result = if self.case_sensitive { + Regex::new(&pattern_str) + } else { + Regex::new(&format!("(?i){}", pattern_str)) + }; + + self.regex_pattern = regex_result.ok(); + } + + /// Test if a string matches the search pattern + fn matches(&self, text: &str) -> bool { + if !self.filter_active || self.query.is_empty() { + return true; // No filter, everything matches + } + + // Fast path: for literal search (no regex), use simple string contains + if !self.query.starts_with('!') { + // Literal search - much faster than regex + if self.case_sensitive { + return text.contains(&self.query); + } else { + return text.to_lowercase().contains(&self.query.to_lowercase()); + } + } + + // Regex path + match &self.regex_pattern { + Some(regex) => regex.is_match(text), + None => true, // Invalid regex, show everything + } + } + + /// Activate search mode + fn activate(&mut self) { + self.active = true; + } + + /// Deactivate and apply filter + fn apply(&mut self) { + self.active = false; + self.filter_active = !self.query.is_empty(); + self.compile_pattern(); + } + + /// Cancel search without applying + fn cancel(&mut self) { + self.active = false; + self.query.clear(); + self.filter_active = false; + self.regex_pattern = None; + } + + /// Clear the search filter + fn clear(&mut self) { + self.query.clear(); + self.filter_active = false; + self.regex_pattern = None; + } + + /// Add character to query + fn push_char(&mut self, c: char) { + self.query.push(c); + } + + /// Remove last character from query + fn pop_char(&mut self) { + self.query.pop(); + } + + /// Toggle case sensitivity + fn toggle_case_sensitive(&mut self) { + self.case_sensitive = !self.case_sensitive; + if self.filter_active { + self.compile_pattern(); + } + } +} + +pub struct App { + running: bool, + current_page: String, + nav_stack: NavigationStack, + nav_context: NavigationContext, + action_executor: ActionExecutor, + adapter_registry: Arc, + + // Current view state + current_data: Vec, + filtered_data: Vec, + selected_index: usize, + scroll_offset: usize, + table_state: ratatui::widgets::TableState, + loading: bool, + error_message: Option, + + // Global search (works across all views) + global_search: GlobalSearch, + + // Confirmation dialogs + show_quit_confirm: bool, + action_confirm: Option, + + // Action result message + action_message: Option, + + // Auto-refresh timer + last_refresh: std::time::Instant, + + // Stream state + stream_active: bool, + stream_paused: bool, + stream_buffer: VecDeque, + stream_frozen_snapshot: VecDeque, // Frozen snapshot when paused + stream_receiver: Option>, + stream_status: StreamStatus, + + // Logs view settings + logs_follow: bool, + logs_wrap: bool, + logs_horizontal_scroll: usize, + + // Action mode (prefix key pattern) + action_mode: bool, + + // UI state + needs_clear: bool, + needs_render: bool, + + // Data refresh watcher + refresh_receiver: Option>, +} + +#[derive(Debug)] +struct RefreshMessage { + page_name: String, + data: Vec, +} + +#[derive(Clone)] +struct ActionConfirm { + action: crate::config::schema::Action, + message: String, +} + +#[derive(Clone)] +struct ActionMessage { + message: String, + message_type: MessageType, + timestamp: std::time::Instant, +} + +#[derive(Clone, Copy, PartialEq)] +#[allow(dead_code)] +enum MessageType { + Success, + Error, + Info, + Warning, +} + +#[derive(Debug, Clone, PartialEq)] +enum StreamStatus { + Idle, + Connected, + Streaming, + Stopped, + Error(String), +} + +impl App { + pub fn new( + config: Config, + adapter_registry: crate::adapters::registry::AdapterRegistry, + ) -> Result { + let current_page = config.start.clone(); + let nav_context = NavigationContext::new().with_globals(config.globals.clone()); + let action_executor = ActionExecutor::new(Arc::new(globals::template_engine().clone())); + + Ok(Self { + running: false, + current_page, + nav_stack: NavigationStack::default(), + nav_context, + action_executor, + adapter_registry: Arc::new(adapter_registry), + current_data: Vec::new(), + filtered_data: Vec::new(), + selected_index: 0, + scroll_offset: 0, + table_state: ratatui::widgets::TableState::default(), + loading: false, + error_message: None, + global_search: GlobalSearch::default(), + show_quit_confirm: false, + action_confirm: None, + action_message: None, + last_refresh: std::time::Instant::now(), + stream_active: false, + stream_paused: false, + stream_buffer: VecDeque::new(), + stream_frozen_snapshot: VecDeque::new(), + stream_receiver: None, + stream_status: StreamStatus::Idle, + logs_follow: true, + logs_wrap: true, + logs_horizontal_scroll: 0, + action_mode: false, + needs_clear: false, + needs_render: true, // Initial render needed + refresh_receiver: None, + }) + } + + pub async fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> { + self.running = true; + + // Load initial page data + self.load_current_page().await; + + while self.running { + if self.needs_clear { + terminal.clear()?; + self.needs_clear = false; + } + + // Check for refresh updates from background watcher + self.check_refresh_updates(); + + // Check for stream updates + self.check_stream_updates(); + + // Auto-dismiss notifications after 3 seconds + if let Some(msg) = &self.action_message { + if msg.timestamp.elapsed() > std::time::Duration::from_secs(3) { + self.action_message = None; + self.needs_render = true; + } + } + + // Only render if needed (data changed, user input, etc.) + if self.needs_render { + // Update table state to match selected_index + self.table_state.select(Some(self.selected_index)); + + terminal.draw(|frame| self.render(frame))?; + self.needs_render = false; + } + + // Poll for user input with timeout + if let Ok(true) = event::poll(std::time::Duration::from_millis(100)) { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + self.handle_key(key).await; + // Don't auto-render on every key press - let handlers decide + // This allows pause mode to truly freeze the display + } + } + } + } + + Ok(()) + } + + async fn load_current_page(&mut self) { + self.loading = true; + self.error_message = None; + + // Stop any active stream from previous page + self.stop_stream(); + + let page = match globals::config().pages.get(&self.current_page).cloned() { + Some(p) => p, + None => { + self.error_message = Some(format!("Page not found: {}", self.current_page)); + self.loading = false; + return; + } + }; + + // Check if this is a stream data source + if let crate::config::DataSource::SingleOrStream(crate::config::SingleOrStream::Stream(_)) = + &page.data + { + // Start streaming + if let Err(e) = self.start_stream(&page).await { + self.error_message = Some(format!("Failed to start stream: {}", e)); + self.loading = false; + } else { + self.loading = false; + } + return; + } + + // Fetch data for non-stream sources + match self.fetch_page_data(&page).await { + Ok(data) => { + self.current_data = data; + self.apply_sort_and_filter(); + self.selected_index = 0; + self.scroll_offset = 0; + self.loading = false; + self.last_refresh = std::time::Instant::now(); + self.needs_render = true; + + // Spawn background refresh watcher if refresh_interval is set + self.spawn_refresh_watcher(self.current_page.clone(), page); + } + Err(e) => { + self.error_message = Some(format!("Failed to load data: {}", e)); + self.loading = false; + self.needs_render = true; + } + } + } + + fn spawn_refresh_watcher(&mut self, page_name: String, page: crate::config::Page) { + use crate::config::DataSource; + + // Get refresh interval + let refresh_interval = match &page.data { + DataSource::SingleOrStream(crate::config::SingleOrStream::Single(single)) => { + if let Some(interval_str) = &single.refresh_interval { + humantime::parse_duration(interval_str).ok() + } else { + None + } + } + _ => None, + }; + + // Only spawn watcher if refresh_interval is set + let interval = match refresh_interval { + Some(i) => i, + None => return, + }; + + // Create channel for sending refresh updates + let (tx, rx) = mpsc::channel(10); + self.refresh_receiver = Some(rx); + + // Clone necessary data for the background task + let nav_context = self.nav_context.clone(); + let adapter_registry = self.adapter_registry.clone(); + + // Spawn background task + tokio::spawn(async move { + let mut interval_timer = tokio::time::interval(interval); + interval_timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + interval_timer.tick().await; + + // Fetch data in background + let data = Self::fetch_data_static(&page, &nav_context, &adapter_registry).await; + + if let Ok(data) = data { + // Send update through channel + if tx + .send(RefreshMessage { + page_name: page_name.clone(), + data, + }) + .await + .is_err() + { + // Channel closed, exit background task + break; + } + } + } + }); + } + + fn check_refresh_updates(&mut self) { + // Collect all pending messages first + let mut messages = Vec::new(); + if let Some(receiver) = &mut self.refresh_receiver { + while let Ok(msg) = receiver.try_recv() { + messages.push(msg); + } + } + + // Process messages without holding the receiver borrow + for msg in messages { + // Only update if the message is for the current page + if msg.page_name == self.current_page { + self.current_data = msg.data; + self.apply_sort_and_filter(); + self.needs_render = true; + } + } + } + + async fn start_stream(&mut self, page: &crate::config::Page) -> Result<()> { + use crate::config::{DataSource, SingleOrStream}; + use crate::data::StreamProvider; + + let stream_source = match &page.data { + DataSource::SingleOrStream(SingleOrStream::Stream(stream)) => stream, + _ => return Ok(()), + }; + + // Only support CLI streaming for now + let command = stream_source.command.as_ref().ok_or_else(|| { + crate::error::TermStackError::DataProvider("Stream must have command".to_string()) + })?; + + // Render command and args with templates + let ctx = self.create_template_context(None); + let rendered_command = globals::template_engine().render_string(command, &ctx)?; + let rendered_args: Result> = stream_source + .args + .iter() + .map(|arg| globals::template_engine().render_string(arg, &ctx)) + .collect(); + let rendered_args = rendered_args?; + + // Create stream provider + let mut provider = StreamProvider::new(rendered_command) + .with_args(rendered_args) + .with_shell(stream_source.shell); + + if let Some(working_dir) = &stream_source.working_dir { + provider = provider.with_working_dir(working_dir.clone()); + } + + if !stream_source.env.is_empty() { + provider = provider.with_env(stream_source.env.clone()); + } + + // Start streaming + let receiver = provider.start_stream()?; + + // Update state + self.stream_receiver = Some(receiver); + self.stream_active = true; + self.stream_paused = false; + self.stream_buffer.clear(); + self.stream_status = StreamStatus::Connected; + self.selected_index = 0; + self.scroll_offset = 0; + self.needs_clear = true; // Force full terminal clear on stream start + + Ok(()) + } + + fn stop_stream(&mut self) { + if self.stream_active { + self.needs_clear = true; + } + self.stream_receiver = None; + self.stream_active = false; + self.stream_paused = false; + self.stream_status = StreamStatus::Stopped; + } + + fn check_stream_updates(&mut self) { + if !self.stream_active { + return; + } + + // Get buffer size limit from config + let page = match globals::config().pages.get(&self.current_page) { + Some(p) => p, + None => return, + }; + + let buffer_size = match &page.data { + crate::config::DataSource::SingleOrStream(crate::config::SingleOrStream::Stream( + stream, + )) => stream.buffer_size, + _ => 100, + }; + + // Check for new messages + if let Some(receiver) = &mut self.stream_receiver { + while let Ok(msg) = receiver.try_recv() { + match msg { + StreamMessage::Connected => { + self.stream_status = StreamStatus::Streaming; + self.needs_render = true; + } + StreamMessage::Data(line) => { + self.stream_status = StreamStatus::Streaming; + + // Add to buffer + self.stream_buffer.push_back(line); + + // Remove oldest if buffer is full + while self.stream_buffer.len() > buffer_size { + self.stream_buffer.pop_front(); + } + + // Only trigger render and update position when NOT paused + if !self.stream_paused { + // Auto-scroll to bottom if follow is enabled + if self.logs_follow { + self.selected_index = self.stream_buffer.len().saturating_sub(1); + } + self.needs_render = true; + } + // When paused: buffer is updated but NO render triggered + // View stays frozen on the same content + } + StreamMessage::End => { + self.stream_status = StreamStatus::Stopped; + self.stream_active = false; + self.needs_render = true; + } + StreamMessage::Error(err) => { + self.stream_status = StreamStatus::Error(err.clone()); + self.stream_active = false; + self.error_message = Some(format!("Stream error: {}", err)); + self.needs_render = true; + } + } + } + } + } + + async fn fetch_page_data(&self, page: &crate::config::Page) -> Result> { + use crate::config::DataSource; + + let data_source = &page.data; + + match data_source { + DataSource::SingleOrStream(crate::config::SingleOrStream::Single(single)) => { + // Create data context for template rendering + let data_context = crate::data::provider::DataContext { + globals: self.nav_context.globals.clone(), + page_contexts: self.nav_context.page_contexts.clone(), + }; + + // Fetch data using adapter registry + let result = self + .adapter_registry + .fetch(single, &data_context) + .await + .map_err(|e| crate::error::TermStackError::DataProvider(e.to_string()))?; + + // Extract items using JSONPath + let items = if let Some(items_path) = &single.items { + let extractor = JsonPathExtractor::new(items_path)?; + extractor.extract(&result)? + } else { + vec![result] + }; + + Ok(items) + } + DataSource::Multi(_) => Err(crate::error::TermStackError::DataProvider( + "Multi-source not yet implemented".to_string(), + )), + DataSource::SingleOrStream(crate::config::SingleOrStream::Stream(_)) => { + // Stream sources don't use fetch_page_data + // They will be handled separately with streaming infrastructure + Ok(Vec::new()) + } + } + } + + fn create_template_context(&self, current_row: Option<&Value>) -> TemplateContext { + let mut ctx = TemplateContext::new().with_globals(self.nav_context.globals.clone()); + + for (page, data) in &self.nav_context.page_contexts { + ctx = ctx.with_page_context(page.clone(), data.clone()); + } + + if let Some(row) = current_row { + ctx = ctx.with_current(row.clone()); + } + + ctx + } + + // Static version of fetch_page_data for background tasks + async fn fetch_data_static( + page: &crate::config::Page, + nav_context: &NavigationContext, + adapter_registry: &crate::adapters::registry::AdapterRegistry, + ) -> Result> { + use crate::config::DataSource; + + let data_source = &page.data; + + match data_source { + DataSource::SingleOrStream(crate::config::SingleOrStream::Single(single)) => { + // Create data context for template rendering + let data_context = crate::data::provider::DataContext { + globals: nav_context.globals.clone(), + page_contexts: nav_context.page_contexts.clone(), + }; + + // Fetch data using adapter registry + let result = adapter_registry + .fetch(single, &data_context) + .await + .map_err(|e| crate::error::TermStackError::DataProvider(e.to_string()))?; + + // Extract items using JSONPath + let items = if let Some(items_path) = &single.items { + let extractor = JsonPathExtractor::new(items_path)?; + extractor.extract(&result)? + } else { + vec![result] + }; + + Ok(items) + } + DataSource::Multi(_) => Err(crate::error::TermStackError::DataProvider( + "Multi-source not yet implemented".to_string(), + )), + DataSource::SingleOrStream(crate::config::SingleOrStream::Stream(_)) => Ok(Vec::new()), + } + } + + async fn handle_key(&mut self, key: KeyEvent) { + // Handle action confirmation dialog + if let Some(confirm) = &self.action_confirm { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + let action = confirm.action.clone(); + self.action_confirm = None; + self.execute_action(&action).await; + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + self.action_confirm = None; + self.needs_render = true; + } + _ => {} + } + return; + } + + // Handle quit confirmation dialog + if self.show_quit_confirm { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + self.running = false; + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + self.show_quit_confirm = false; + self.needs_render = true; + } + _ => {} + } + return; + } + + // Handle global search mode + if self.global_search.active { + match key.code { + KeyCode::Char(c) + if c == 'C' + && key + .modifiers + .contains(crossterm::event::KeyModifiers::CONTROL) => + { + // Ctrl+C: Toggle case sensitivity + self.global_search.toggle_case_sensitive(); + return; + } + KeyCode::Char(c) => { + self.global_search.push_char(c); + self.needs_render = true; + return; + } + KeyCode::Backspace => { + self.global_search.pop_char(); + self.needs_render = true; + return; + } + KeyCode::Enter => { + // Apply the search filter + self.global_search.apply(); + // Re-filter the data for table views + if !self.stream_active { + self.apply_sort_and_filter(); + self.selected_index = 0; + self.needs_render = true; + } else { + // For stream views, trigger render to apply filter + self.selected_index = 0; + self.needs_render = true; + } + return; + } + KeyCode::Esc => { + // Cancel search and clear filter + self.global_search.cancel(); + // Re-filter the data for table views + if !self.stream_active { + self.apply_sort_and_filter(); + self.selected_index = 0; + self.needs_render = true; + } else { + // For stream views, trigger render to clear filter + self.selected_index = 0; + self.needs_render = true; + } + return; + } + _ => return, + } + } + + // Clear action message on any key + if self.action_message.is_some() { + self.action_message = None; + } + + // Normal key handling + match key.code { + KeyCode::Char('q') => { + // Always show quit confirmation + self.show_quit_confirm = true; + self.needs_render = true; + } + KeyCode::Esc => { + // If in action mode, exit action mode first + if self.action_mode { + self.action_mode = false; + self.needs_render = true; + } + // If search filter is active, clear it first + else if self.global_search.filter_active { + self.global_search.clear(); + // Re-filter the data for table views + if !self.stream_active { + self.apply_sort_and_filter(); + self.selected_index = 0; + } + self.needs_render = true; + } else if !self.nav_stack.is_empty() { + self.go_back().await; + } + } + KeyCode::Char('j') | KeyCode::Down => self.move_down(), + KeyCode::Char('k') | KeyCode::Up => self.move_up(), + KeyCode::Char('g') => { + if self.action_mode { + self.action_mode = false; + self.handle_action_key('g').await; + } else { + self.move_top(); + } + } + KeyCode::Char('G') => self.move_bottom(), + KeyCode::Char('r') => self.load_current_page().await, + KeyCode::Char('/') => { + // Activate global search + self.global_search.activate(); + self.needs_render = true; + } + KeyCode::Char('f') => { + if self.action_mode { + self.action_mode = false; + self.handle_action_key('f').await; + return; + } + // Toggle follow in logs view (when paused, 'f' resumes LIVE mode) + if self.stream_active { + if self.stream_paused { + // Currently paused, resume to LIVE + self.stream_paused = false; + self.logs_follow = true; + // Clear the frozen snapshot + self.stream_frozen_snapshot.clear(); + if !self.stream_buffer.is_empty() { + self.selected_index = self.stream_buffer.len() - 1; + } + self.needs_render = true; // Force render when resuming + } else { + // Currently live, pause at current position + self.stream_paused = true; + self.logs_follow = false; + // Take a snapshot of the current buffer + self.stream_frozen_snapshot = self.stream_buffer.clone(); + self.needs_render = true; // Force render to update status indicator + } + } + } + KeyCode::Char('w') => { + // Toggle wrap in logs view + if self.stream_active { + self.logs_wrap = !self.logs_wrap; + // Reset horizontal scroll when enabling wrap + if self.logs_wrap { + self.logs_horizontal_scroll = 0; + } + // Always render user actions, even when paused + self.needs_render = true; + } + } + KeyCode::Left => { + // Scroll left in logs view (when wrap is off) + if self.stream_active && !self.logs_wrap { + self.logs_horizontal_scroll = self.logs_horizontal_scroll.saturating_sub(5); + // Always render user actions, even when paused + self.needs_render = true; + } + } + KeyCode::Right => { + // Scroll right in logs view (when wrap is off) + if self.stream_active && !self.logs_wrap { + self.logs_horizontal_scroll = self.logs_horizontal_scroll.saturating_add(5); + // Always render user actions, even when paused + self.needs_render = true; + } + } + KeyCode::Char('h') => { + // In action mode: treat as action key + if self.action_mode { + self.action_mode = false; + self.handle_action_key('h').await; + } else if self.stream_active && !self.logs_wrap { + // Normal mode: horizontal scroll left in logs view + self.logs_horizontal_scroll = self.logs_horizontal_scroll.saturating_sub(5); + // Always render user actions, even when paused + self.needs_render = true; + } + } + KeyCode::Char('l') => { + // In action mode: treat as action key + if self.action_mode { + self.action_mode = false; + self.handle_action_key('l').await; + } else if self.stream_active && !self.logs_wrap { + // Normal mode: horizontal scroll right in logs view + self.logs_horizontal_scroll = self.logs_horizontal_scroll.saturating_add(5); + // Always render user actions, even when paused + self.needs_render = true; + } + } + KeyCode::Enter => { + // In action mode: treat as action key + if self.action_mode { + self.action_mode = false; + self.handle_action_key('\n').await; + } else { + // Normal mode: navigate to next page + self.navigate_next().await; + } + } + KeyCode::Char('a') => { + // Enter action mode (never conflicts because 'a' is the action mode trigger) + if !self.action_mode { + self.action_mode = true; + self.needs_render = true; + } + } + KeyCode::Char(c) => { + // In action mode: ANY character is an action key + if self.action_mode { + self.action_mode = false; + self.handle_action_key(c).await; + } + // Normal mode: ignore unmapped keys (no conflict) + } + _ => {} + } + } + + async fn handle_action_key(&mut self, key: char) { + // Find matching action and clone it to avoid borrow issues + let action_to_execute = { + let page = match globals::config().pages.get(&self.current_page) { + Some(p) => p, + None => return, + }; + + let actions = match &page.actions { + Some(a) => a, + None => return, + }; + + // Find action with matching key + actions + .iter() + .find(|action| action.key == key.to_string()) + .cloned() + }; + + if let Some(action) = action_to_execute { + // Check if confirmation is needed + if let Some(confirm_msg) = &action.confirm { + // Render confirmation message with context + let rendered_msg = globals::template_engine() + .render_string( + confirm_msg, + &self.create_template_context(self.get_selected_row()), + ) + .unwrap_or_else(|_| confirm_msg.clone()); + + self.action_confirm = Some(ActionConfirm { + action: action.clone(), + message: rendered_msg, + }); + } else { + // Execute immediately + self.execute_action(&action).await; + } + } + } + + fn get_selected_row(&self) -> Option<&Value> { + self.filtered_data.get(self.selected_index) + } + + fn create_template_context_map(&self) -> std::collections::HashMap { + let mut context = std::collections::HashMap::new(); + + // Add globals + for (key, value) in &self.nav_context.globals { + context.insert(key.clone(), value.clone()); + } + + // Add page contexts + for (page, data) in &self.nav_context.page_contexts { + context.insert(page.clone(), data.clone()); + } + + // Add current row data + if let Some(row) = self.get_selected_row() { + context.insert("row".to_string(), row.clone()); + context.insert("value".to_string(), row.clone()); + + // Flatten current object fields + if let Value::Object(map) = row { + for (key, value) in map { + context.insert(key.clone(), value.clone()); + } + } + } + + context + } + + async fn execute_action(&mut self, action: &crate::config::schema::Action) { + // Create template context for rendering custom messages + let selected_row = self.get_selected_row(); + let template_ctx = self.create_template_context(selected_row); + + // Create context map for action executor + let context = self.create_template_context_map(); + + // Execute action + match self.action_executor.execute(action, &context).await { + Ok(ActionResult::Success(_msg)) => { + // Only show notification if explicitly configured + if let Some(notification) = &action.notification { + if let Some(custom_msg) = ¬ification.on_success { + let message = globals::template_engine() + .render_string(custom_msg, &template_ctx) + .unwrap_or_else(|_| custom_msg.clone()); + + self.action_message = Some(ActionMessage { + message, + message_type: MessageType::Success, + timestamp: std::time::Instant::now(), + }); + self.needs_render = true; + } + } else if let Some(success_msg) = &action.success_message { + // Legacy support for success_message + let message = globals::template_engine() + .render_string(success_msg, &template_ctx) + .unwrap_or_else(|_| success_msg.clone()); + + self.action_message = Some(ActionMessage { + message, + message_type: MessageType::Success, + timestamp: std::time::Instant::now(), + }); + self.needs_render = true; + } + // If neither notification nor success_message is set, execute silently + } + Ok(ActionResult::Error(msg)) => { + // Always show errors, but use custom message if configured + let message = if let Some(notification) = &action.notification { + if let Some(custom_msg) = ¬ification.on_failure { + globals::template_engine() + .render_string(custom_msg, &template_ctx) + .unwrap_or_else(|_| custom_msg.clone()) + } else { + msg + } + } else if let Some(error_msg) = &action.error_message { + globals::template_engine() + .render_string(error_msg, &template_ctx) + .unwrap_or_else(|_| error_msg.clone()) + } else { + msg + }; + + self.action_message = Some(ActionMessage { + message, + message_type: MessageType::Error, + timestamp: std::time::Instant::now(), + }); + self.needs_render = true; + } + Ok(ActionResult::Refresh) => { + // Show success notification if configured, then reload + if let Some(notification) = &action.notification { + if let Some(custom_msg) = ¬ification.on_success { + let message = globals::template_engine() + .render_string(custom_msg, &template_ctx) + .unwrap_or_else(|_| custom_msg.clone()); + + self.action_message = Some(ActionMessage { + message, + message_type: MessageType::Success, + timestamp: std::time::Instant::now(), + }); + self.needs_render = true; + } + } else if let Some(success_msg) = &action.success_message { + // Legacy support for success_message + let message = globals::template_engine() + .render_string(success_msg, &template_ctx) + .unwrap_or_else(|_| success_msg.clone()); + + self.action_message = Some(ActionMessage { + message, + message_type: MessageType::Success, + timestamp: std::time::Instant::now(), + }); + self.needs_render = true; + } + + // Reload the page + self.load_current_page().await; + } + Ok(ActionResult::Navigate(page, context_map)) => { + // Navigate to the specified page with context + self.navigate_to_page(&page, context_map).await; + } + Err(e) => { + // Use custom error notification message if available for executor errors + let message = if let Some(notification) = &action.notification { + if let Some(custom_msg) = ¬ification.on_failure { + globals::template_engine() + .render_string(custom_msg, &template_ctx) + .unwrap_or_else(|_| format!("Action failed: {}", e)) + } else { + format!("Action failed: {}", e) + } + } else if let Some(error_msg) = &action.error_message { + globals::template_engine() + .render_string(error_msg, &template_ctx) + .unwrap_or_else(|_| format!("Action failed: {}", e)) + } else { + format!("Action failed: {}", e) + }; + + self.action_message = Some(ActionMessage { + message, + message_type: MessageType::Error, + timestamp: std::time::Instant::now(), + }); + self.needs_render = true; + } + } + } + + async fn navigate_to_page( + &mut self, + target_page: &str, + context_map: std::collections::HashMap, + ) { + // Get the current selected row + let selected_row = self.get_selected_row().cloned(); + + // Render context values with template engine + let mut rendered_context = std::collections::HashMap::new(); + if let Some(row) = &selected_row { + let template_ctx = self.create_template_context(Some(row)); + + for (key, template) in context_map { + match globals::template_engine().render_string(&template, &template_ctx) { + Ok(rendered) => { + rendered_context.insert(key, serde_json::json!(rendered)); + } + Err(e) => { + self.error_message = Some(format!("Failed to render context: {}", e)); + return; + } + } + } + } + + // Save current page ID before navigation + let source_page_id = self.current_page.clone(); + + // Save current state to navigation stack + let frame = NavigationFrame { + page_id: source_page_id.clone(), + context: HashMap::new(), + scroll_offset: self.scroll_offset, + selected_index: self.selected_index, + }; + self.nav_stack.push(frame); + + // Update navigation context with new data + for (key, value) in rendered_context { + self.nav_context.page_contexts.insert(key, value); + } + + // Also store the entire selected row under the current page name + // This allows templates like "Pods - {{ namespaces.metadata.name }}" to work + if let Some(row) = selected_row { + self.nav_context.set_page_context(source_page_id, row); + } + + // Navigate to new page + self.current_page = target_page.to_string(); + self.selected_index = 0; + self.scroll_offset = 0; + + // Load new page data + self.load_current_page().await; + } + + fn move_down(&mut self) { + // Check if we're in a text view + if let Some(page) = globals::config().pages.get(&self.current_page) { + if matches!(page.view, ConfigView::Text(_)) { + // Text view: scroll down by one line + self.scroll_offset += 1; + self.needs_render = true; + return; + } + } + + let max_index = if self.stream_active || !self.stream_buffer.is_empty() { + // Stream mode: use display buffer (frozen snapshot if paused) + let display_buffer_len = + if self.stream_paused && !self.stream_frozen_snapshot.is_empty() { + self.stream_frozen_snapshot.len() + } else { + self.stream_buffer.len() + }; + if display_buffer_len == 0 { + return; + } + display_buffer_len - 1 + } else { + // Table mode: use filtered data + if self.filtered_data.is_empty() { + return; + } + self.filtered_data.len() - 1 + }; + + if self.selected_index < max_index { + self.selected_index += 1; + // Always render cursor movement, even when paused + self.needs_render = true; + } + } + + fn move_up(&mut self) { + // Check if we're in a text view + if let Some(page) = globals::config().pages.get(&self.current_page) { + if matches!(page.view, ConfigView::Text(_)) { + // Text view: scroll up by one line + if self.scroll_offset > 0 { + self.scroll_offset -= 1; + self.needs_render = true; + } + return; + } + } + + if self.selected_index > 0 { + self.selected_index -= 1; + // Always render cursor movement, even when paused + self.needs_render = true; + } + } + + fn move_top(&mut self) { + // Check if we're in a text view + if let Some(page) = globals::config().pages.get(&self.current_page) { + if matches!(page.view, ConfigView::Text(_)) { + // Text view: scroll to top + self.scroll_offset = 0; + self.needs_render = true; + return; + } + } + + self.selected_index = 0; + // Always render cursor movement, even when paused + self.needs_render = true; + } + + fn move_bottom(&mut self) { + // Check if we're in a text view + if let Some(page) = globals::config().pages.get(&self.current_page) { + if matches!(page.view, ConfigView::Text(_)) { + // Text view: scroll to bottom (will be clamped in render_text) + self.scroll_offset = usize::MAX; + self.needs_render = true; + return; + } + } + + if self.stream_active || !self.stream_buffer.is_empty() { + // Stream mode - jumping to bottom does NOT change pause state + // Use display buffer (frozen snapshot if paused) + let display_buffer_len = + if self.stream_paused && !self.stream_frozen_snapshot.is_empty() { + self.stream_frozen_snapshot.len() + } else { + self.stream_buffer.len() + }; + if display_buffer_len > 0 { + self.selected_index = display_buffer_len - 1; + // Always render cursor movement, even when paused + self.needs_render = true; + } + } else { + // Table mode + if !self.filtered_data.is_empty() { + self.selected_index = self.filtered_data.len() - 1; + self.needs_render = true; + } + } + } + + async fn go_back(&mut self) { + if let Some(frame) = self.nav_stack.pop() { + // Stop any active stream before navigating back + self.stop_stream(); + + // Clear search when navigating back + self.global_search.clear(); + + self.current_page = frame.page_id; + self.selected_index = frame.selected_index; + self.scroll_offset = frame.scroll_offset; + self.load_current_page().await; + } + } + + async fn navigate_next(&mut self) { + let page = match globals::config().pages.get(&self.current_page) { + Some(p) => p, + None => return, + }; + + let next_nav = match &page.next { + Some(nav) => nav, + None => return, + }; + + use crate::config::Navigation; + let (next_page, context_map) = match next_nav { + Navigation::Simple(simple) => (&simple.page, &simple.context), + Navigation::Conditional(conditionals) => { + // Find first matching condition or default + let mut found = None; + let mut default_found = None; + + // Get selected row for condition evaluation + let selected_row = self.filtered_data.get(self.selected_index); + + for cond in conditionals { + if cond.default { + default_found = Some((&cond.page, &cond.context)); + continue; + } + + // Evaluate condition if present + if let Some(condition) = &cond.condition { + if let Some(row) = selected_row { + let ctx = self.create_template_context(Some(row)); + let matches = globals::template_engine() + .render_string(condition, &ctx) + .map(|result| result.trim() == "true") + .unwrap_or(false); + + if matches { + found = Some((&cond.page, &cond.context)); + break; + } + } + } + } + + // Use first matching condition, or fall back to default + match found.or(default_found) { + Some(f) => f, + None => return, + } + } + }; + + // Save current frame to navigation stack + let mut frame = NavigationFrame::new(self.current_page.clone()); + frame.selected_index = self.selected_index; + frame.scroll_offset = self.scroll_offset; + self.nav_stack.push(frame); + + // Capture context from selected row + if let Some(selected_row) = self.filtered_data.get(self.selected_index) { + for (key, json_path) in context_map { + if let Ok(extractor) = JsonPathExtractor::new(json_path) { + if let Ok(Some(value)) = extractor.extract_single(selected_row) { + self.nav_context.set_page_context(key.clone(), value); + } + } + } + + // Also store the entire selected row under the current page name + self.nav_context + .set_page_context(self.current_page.clone(), selected_row.clone()); + } + + // Clear search when navigating to next page + self.global_search.clear(); + + // Navigate to next page + self.current_page = next_page.clone(); + self.load_current_page().await; + } + + fn render(&mut self, frame: &mut Frame) { + let area = frame.area(); + + // Dynamically adjust header size based on search state + let header_height = if self.global_search.active { + 6 // Breadcrumb + search input + } else { + 3 // Just breadcrumb (with inline filter tag if active) + }; + + let chunks = Layout::vertical([ + Constraint::Length(header_height), // Header + Constraint::Min(0), // Content + Constraint::Length(4), // Status bar + ]) + .split(area); + + self.render_header(frame, chunks[0]); + self.render_content(frame, chunks[1]); + self.render_statusbar(frame, chunks[2]); + + // Render action message if present + if let Some(msg) = &self.action_message { + self.render_action_message(frame, area, msg); + } + + // Render action confirmation dialog on top if active + if let Some(confirm) = &self.action_confirm { + self.render_action_confirm(frame, area, confirm); + } + + // Render quit confirmation dialog on top if active + if self.show_quit_confirm { + self.render_quit_confirm(frame, area); + } + } + + fn render_header(&self, frame: &mut Frame, area: Rect) { + // Only show search input if actively typing + if self.global_search.active { + let header_chunks = Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([ + Constraint::Length(3), // Breadcrumb with filter tag + Constraint::Length(3), // Search input + ]) + .split(area); + + // Render breadcrumb + self.render_breadcrumb(frame, header_chunks[0]); + + // Render search input + self.render_search_input(frame, header_chunks[1]); + } else { + // Just show breadcrumb (with filter tag if active) + self.render_breadcrumb(frame, area); + } + } + + fn render_breadcrumb(&self, frame: &mut Frame, area: Rect) { + let mut spans = vec![ + Span::styled( + &globals::config().app.name, + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" | "), + ]; + + // Add pages from navigation stack (if any) + for (idx, nav_frame) in self.nav_stack.frames().iter().enumerate() { + if idx > 0 { + spans.push(Span::raw(" > ")); + } + spans.push(Span::styled( + &nav_frame.page_id, + Style::default().fg(Color::White), + )); + } + + // Add separator before current page if there are previous pages + if !self.nav_stack.frames().is_empty() { + spans.push(Span::raw(" > ")); + } + + // Add current page with distinct color + spans.push(Span::styled( + &self.current_page, + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + )); + + let header = + Paragraph::new(Line::from(spans)).block(Block::default().borders(Borders::ALL)); + frame.render_widget(header, area); + } + + fn render_search_input(&self, frame: &mut Frame, area: Rect) { + // Only renders during active input + let search_text = format!("{}_", self.global_search.query); + + let case_indicator = if self.global_search.case_sensitive { + " [Case-sensitive]" + } else { + "" + }; + + let mode_indicator = if self.global_search.query.starts_with('!') { + " (Regex)" + } else { + " (Literal)" + }; + + let title = format!( + "Search{}{} - Enter to apply, Esc to cancel", + mode_indicator, case_indicator + ); + + let search_input = Paragraph::new(search_text) + .style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .block( + Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(Style::default().fg(Color::Yellow)), + ); + + frame.render_widget(search_input, area); + } + + fn render_content(&mut self, frame: &mut Frame, area: Rect) { + if self.loading { + let loading = Paragraph::new("Loading...") + .style(Style::default().fg(Color::Yellow)) + .block(Block::default().borders(Borders::ALL).title("Content")); + frame.render_widget(loading, area); + return; + } + + if let Some(error) = &self.error_message { + let error_widget = Paragraph::new(error.as_str()) + .style(Style::default().fg(Color::Red)) + .block(Block::default().borders(Borders::ALL).title("Error")); + frame.render_widget(error_widget, area); + return; + } + + let page = match globals::config().pages.get(&self.current_page) { + Some(p) => p, + None => return, + }; + + match &page.view { + ConfigView::Table(table_view) => { + let table_view = table_view.clone(); + self.render_table(frame, area, &table_view); + } + ConfigView::Logs(logs_view) => { + let logs_view = logs_view.clone(); + self.render_logs(frame, area, &logs_view); + } + ConfigView::Text(text_view) => { + self.render_text(frame, area, text_view); + } + } + } + + fn render_table( + &mut self, + frame: &mut Frame, + area: Rect, + table_config: &crate::config::TableView, + ) { + // Get the rendered page title + let page_title = self.get_rendered_page_title(); + + if self.filtered_data.is_empty() { + let empty = Paragraph::new("No data") + .block(Block::default().borders(Borders::ALL).title(page_title)); + frame.render_widget(empty, area); + return; + } + + // Build header + let header_cells: Vec = table_config + .columns + .iter() + .map(|col| { + Cell::from(col.display.clone()).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + }) + .collect(); + let header = Row::new(header_cells).height(1); + + // Build rows with styling + let _ctx = self.create_template_context(None); + let rows: Vec = self + .filtered_data + .iter() + .enumerate() + .map(|(_idx, item)| { + let cells: Vec = table_config + .columns + .iter() + .map(|col| { + // Extract value using JSONPath + let (value_str, extracted_value) = + if let Ok(extractor) = JsonPathExtractor::new(&col.path) { + if let Ok(Some(value)) = extractor.extract_single(item) { + // Apply transform if present + let display_str = if let Some(transform) = &col.transform { + // Create context with full row for transform + let mut row_ctx = self.create_template_context(Some(item)); + // Add the extracted value as "value" page context for easy access in transforms + row_ctx = row_ctx + .with_page_context("value".to_string(), value.clone()); + // Also add the full row as "row" for conditions + row_ctx = row_ctx + .with_page_context("row".to_string(), item.clone()); + + globals::template_engine() + .render_string(transform, &row_ctx) + .unwrap_or_else(|_| value_to_string(&value)) + } else { + value_to_string(&value) + }; + (display_str, Some(value)) + } else { + ("".to_string(), None) + } + } else { + ("".to_string(), None) + }; + + // Apply column styling + let cell_style = self.apply_column_style(col, &extracted_value, item); + Cell::from(value_str).style(cell_style) + }) + .collect(); + + // Apply row-level styling + let row_style = self.apply_row_style(table_config, item); + Row::new(cells).style(row_style) + }) + .collect(); + + // Calculate column widths + let widths: Vec = table_config + .columns + .iter() + .map(|col| { + if let Some(width) = col.width { + Constraint::Length(width) + } else { + Constraint::Percentage((100 / table_config.columns.len()) as u16) + } + }) + .collect(); + + let table = Table::new(rows, widths) + .header(header) + .block(Block::default().borders(Borders::ALL).title(page_title)) + .row_highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(">> "); + + // Use stateful rendering for efficient highlight updates + frame.render_stateful_widget(table, area, &mut self.table_state); + } + + /// Apply column-level conditional styling + fn apply_column_style( + &self, + col: &crate::config::TableColumn, + value: &Option, + row: &Value, + ) -> Style { + let mut style = Style::default(); + + // Find the first matching style rule + for style_rule in &col.style { + let matches = if let Some(condition) = &style_rule.condition { + // Evaluate condition template + let mut ctx = self.create_template_context(Some(row)); + if let Some(val) = value { + ctx = ctx.with_page_context("value".to_string(), val.clone()); + } + ctx = ctx.with_page_context("row".to_string(), row.clone()); + + globals::template_engine() + .render_string(condition, &ctx) + .map(|result| result.trim() == "true") + .unwrap_or(false) + } else if style_rule.default { + true + } else { + false + }; + + if matches { + // Apply this style + if let Some(color_str) = &style_rule.color { + if let Some(color) = Self::parse_color(color_str) { + style = style.fg(color); + } + } + if let Some(bg_str) = &style_rule.bg { + if let Some(bg_color) = Self::parse_color(bg_str) { + style = style.bg(bg_color); + } + } + if style_rule.bold { + style = style.add_modifier(Modifier::BOLD); + } + if style_rule.dim { + style = style.add_modifier(Modifier::DIM); + } + break; // Use first matching rule + } + } + + style + } + + /// Apply row-level conditional styling + fn apply_row_style(&self, table_config: &crate::config::TableView, row: &Value) -> Style { + let mut style = Style::default(); + + // Find the first matching row style rule + for style_rule in &table_config.row_style { + let matches = if let Some(condition) = &style_rule.condition { + // Evaluate condition template + let ctx = self.create_template_context(Some(row)); + globals::template_engine() + .render_string(condition, &ctx) + .map(|result| result.trim() == "true") + .unwrap_or(false) + } else if style_rule.default { + true + } else { + false + }; + + if matches { + // Apply this style + if let Some(color_str) = &style_rule.color { + if let Some(color) = Self::parse_color(color_str) { + style = style.fg(color); + } + } + if let Some(bg_str) = &style_rule.bg { + if let Some(bg_color) = Self::parse_color(bg_str) { + style = style.bg(bg_color); + } + } + if style_rule.bold { + style = style.add_modifier(Modifier::BOLD); + } + if style_rule.dim { + style = style.add_modifier(Modifier::DIM); + } + break; // Use first matching rule + } + } + + style + } + + /// Parse color string to ratatui Color + fn parse_color(color_str: &str) -> Option { + match color_str.to_lowercase().as_str() { + "black" => Some(Color::Black), + "red" => Some(Color::Red), + "green" => Some(Color::Green), + "yellow" => Some(Color::Yellow), + "blue" => Some(Color::Blue), + "magenta" => Some(Color::Magenta), + "cyan" => Some(Color::Cyan), + "gray" | "grey" => Some(Color::Gray), + "darkgray" | "darkgrey" => Some(Color::DarkGray), + "lightred" => Some(Color::LightRed), + "lightgreen" => Some(Color::LightGreen), + "lightyellow" => Some(Color::LightYellow), + "lightblue" => Some(Color::LightBlue), + "lightmagenta" => Some(Color::LightMagenta), + "lightcyan" => Some(Color::LightCyan), + "white" => Some(Color::White), + _ => None, + } + } + + fn render_text( + &mut self, + frame: &mut Frame, + area: Rect, + text_config: &crate::config::schema::TextView, + ) { + let page_title = self.get_rendered_page_title(); + + if self.current_data.is_empty() { + let msg = Paragraph::new("No data") + .block(Block::default().borders(Borders::ALL).title(page_title)); + frame.render_widget(msg, area); + return; + } + + // Get the first item (text views typically show single document) + let item = &self.current_data[0]; + + // Convert to string representation + let content_str = if item.is_string() { + // Already a string - check if it's JSON and re-format for proper indentation + let raw = item.as_str().unwrap_or(""); + if let Ok(json_val) = serde_json::from_str::(raw) { + // Re-parse and pretty-print JSON + serde_json::to_string_pretty(&json_val).unwrap_or_else(|_| raw.to_string()) + } else { + raw.to_string() + } + } else { + // Convert JSON object to formatted string + serde_json::to_string_pretty(item).unwrap_or_else(|_| "Failed to serialize".to_string()) + }; + + // Auto-detect content type if not specified + let detected_syntax: String = text_config + .syntax + .as_ref() + .map(|s| s.to_string()) + .unwrap_or_else(|| self.detect_content_type(&content_str).to_string()); + + // Apply syntax highlighting + let mut lines = + self.highlight_text(&content_str, &detected_syntax, text_config.line_numbers); + + // Apply search filter if active + if self.global_search.filter_active && !self.global_search.query.is_empty() { + let content_lines: Vec<&str> = content_str.lines().collect(); + lines = lines + .into_iter() + .zip(content_lines.iter()) + .filter(|(_, line_text)| self.global_search.matches(line_text)) + .map(|(line, _)| line) + .collect(); + } + + let total_lines = lines.len(); + + // Calculate visible area + let visible_height = area.height.saturating_sub(2) as usize; // Account for borders + + // Adjust scroll offset to stay within bounds + if self.scroll_offset >= total_lines.saturating_sub(visible_height) { + self.scroll_offset = total_lines.saturating_sub(visible_height); + } + + let scroll_offset = self.scroll_offset; + + // Get visible lines based on scroll offset + let visible_lines: Vec = lines + .into_iter() + .skip(scroll_offset) + .take(visible_height) + .collect(); + + let mut paragraph = Paragraph::new(visible_lines).block( + Block::default().borders(Borders::ALL).title(format!( + "{} [{}] ({}/{})", + page_title, + detected_syntax, + scroll_offset + 1, + total_lines + )), + ); + + if text_config.wrap { + paragraph = paragraph.wrap(ratatui::widgets::Wrap { trim: false }); + } + + frame.render_widget(paragraph, area); + } + + /// Detect content type based on content + fn detect_content_type(&self, content: &str) -> &str { + let trimmed = content.trim_start(); + + // YAML detection + if trimmed.starts_with("---") + || trimmed.contains("apiVersion:") + || trimmed.contains("kind:") + { + return "yaml"; + } + + // JSON detection + if trimmed.starts_with('{') || trimmed.starts_with('[') { + return "json"; + } + + // XML detection + if trimmed.starts_with(" Vec> { + let lines: Vec<&str> = content.lines().collect(); + let line_count = lines.len(); + let line_num_width = line_count.to_string().len(); + + lines + .iter() + .enumerate() + .map(|(idx, line)| { + let mut spans = Vec::new(); + + // Add line numbers if enabled + if line_numbers { + spans.push(Span::styled( + format!("{:>width$} │ ", idx + 1, width = line_num_width), + Style::default().fg(Color::DarkGray), + )); + } + + // Apply syntax-specific highlighting + match syntax { + "yaml" => spans.extend(self.highlight_yaml_line(line)), + "json" => spans.extend(self.highlight_json_line(line)), + "xml" => spans.extend(self.highlight_xml_line(line)), + _ => spans.push(Span::raw(line.to_string())), + } + + Line::from(spans) + }) + .collect() + } + + /// Simple YAML syntax highlighting + fn highlight_yaml_line(&self, line: &str) -> Vec> { + let trimmed = line.trim_start(); + + // Comments + if trimmed.starts_with('#') { + return vec![Span::styled( + line.to_string(), + Style::default().fg(Color::Green), + )]; + } + + // Document separator + if trimmed.starts_with("---") || trimmed.starts_with("...") { + return vec![Span::styled( + line.to_string(), + Style::default().fg(Color::Magenta), + )]; + } + + // Key-value pairs + if let Some(colon_pos) = line.find(':') { + let key = &line[..colon_pos]; + let rest = &line[colon_pos..]; + + vec![ + Span::styled( + key.to_string(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled(rest.to_string(), Style::default().fg(Color::White)), + ] + } else { + vec![Span::raw(line.to_string())] + } + } + + /// Simple JSON syntax highlighting + fn highlight_json_line(&self, line: &str) -> Vec> { + let trimmed = line.trim(); + + // Keys (quoted strings followed by colon) + if trimmed.contains("\":") { + let mut spans = Vec::new(); + let mut current_pos = 0; + + for (idx, ch) in line.char_indices() { + if ch == '"' && idx + 1 < line.len() { + // Find closing quote + if let Some(close_idx) = line[idx + 1..].find('"') { + let close_pos = idx + 1 + close_idx; + if close_pos + 1 < line.len() + && line.chars().nth(close_pos + 1) == Some(':') + { + // This is a key + if current_pos < idx { + spans.push(Span::raw(line[current_pos..idx].to_string())); + } + spans.push(Span::styled( + line[idx..=close_pos].to_string(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )); + current_pos = close_pos + 1; + } + } + } + } + + if current_pos < line.len() { + spans.push(Span::raw(line[current_pos..].to_string())); + } + + spans + } else { + vec![Span::raw(line.to_string())] + } + } + + /// Simple XML syntax highlighting + fn highlight_xml_line(&self, line: &str) -> Vec> { + if line.trim().starts_with('<') { + vec![Span::styled( + line.to_string(), + Style::default().fg(Color::Magenta), + )] + } else { + vec![Span::raw(line.to_string())] + } + } + + /// Parse ANSI escape codes in text and convert to ratatui Line with styles + fn parse_ansi_line(&self, text: &str) -> Line<'static> { + use ansi_to_tui::IntoText; + match text.into_text() { + Ok(parsed_text) => { + // Convert Text to Line - take first line or empty + if parsed_text.lines.is_empty() { + Line::from(text.to_string()) + } else { + parsed_text.lines[0].clone() + } + } + Err(_) => { + // Fallback: return plain text if parsing fails + Line::from(text.to_string()) + } + } + } + + fn render_logs( + &mut self, + frame: &mut Frame, + area: Rect, + _logs_config: &crate::config::schema::LogsView, + ) { + // Get the rendered page title + let page_title = self.get_rendered_page_title(); + + // For streaming logs, render from stream buffer + if self.stream_active || !self.stream_buffer.is_empty() { + // Use frozen snapshot when paused, otherwise use live buffer + let display_buffer = if self.stream_paused && !self.stream_frozen_snapshot.is_empty() { + &self.stream_frozen_snapshot + } else { + &self.stream_buffer + }; + + if display_buffer.is_empty() { + let empty = Paragraph::new("Waiting for data...") + .style(Style::default().fg(Color::Yellow)) + .block(Block::default().borders(Borders::ALL).title(page_title)); + frame.render_widget(empty, area); + return; + } + + // Filter logs using global search if active + let filtered_indices: Vec = if self.global_search.filter_active { + display_buffer + .iter() + .enumerate() + .filter(|(_, line)| self.global_search.matches(line)) + .map(|(idx, _)| idx) + .collect() + } else { + // No filter, use all indices + (0..display_buffer.len()).collect() + }; + + // Calculate visible area + let visible_height = area.height.saturating_sub(2) as usize; // Account for borders + + // When follow is enabled, ensure we stay at the bottom of the buffer + if self.logs_follow && !self.stream_paused { + if !display_buffer.is_empty() { + self.selected_index = display_buffer.len() - 1; + } + } + + // Ensure selected_index is within bounds of the display buffer + if !display_buffer.is_empty() { + self.selected_index = self.selected_index.min(display_buffer.len() - 1); + } + + // Find the position of selected_index in the filtered list + let selected_filter_pos = filtered_indices + .iter() + .position(|&idx| idx == self.selected_index) + .unwrap_or(filtered_indices.len().saturating_sub(1)); + + // Calculate scroll position based on filtered results + let total_lines = filtered_indices.len(); + let mut start_line = selected_filter_pos.saturating_sub(visible_height / 2); + + // Adjust if at the end + if selected_filter_pos + visible_height / 2 >= total_lines { + start_line = total_lines.saturating_sub(visible_height); + } + + let _end_line = (start_line + visible_height).min(total_lines); + + // Build visible lines with optional timestamps and wrapping + let content_width = area.width.saturating_sub(4) as usize; // Account for borders and padding + let mut lines: Vec = Vec::new(); + + for i in start_line..total_lines.min(start_line + visible_height) { + // When wrapping is disabled, limit the number of lines to visible height + // When wrapping is enabled, don't limit since lines may wrap to multiple rows + if !self.logs_wrap && lines.len() >= visible_height { + break; + } + + let actual_idx = filtered_indices[i]; + let line = &display_buffer[actual_idx]; + let display_line = line.clone(); + + // Parse ANSI codes to preserve colors + let mut parsed_line = self.parse_ansi_line(&display_line); + + // Apply selection highlighting if this is the selected line + if actual_idx == self.selected_index { + // Add background to all spans in the line + for span in &mut parsed_line.spans { + span.style = span.style.bg(Color::DarkGray).add_modifier(Modifier::BOLD); + } + } + + // Handle wrapping if enabled + if self.logs_wrap { + // For wrapping, we need to handle line width + let line_width: usize = parsed_line.spans.iter().map(|s| s.content.len()).sum(); + + if line_width > content_width { + // TODO: Proper wrapping with ANSI styles is complex + // For now, just push the line and let Paragraph wrap it + lines.push(parsed_line); + } else { + lines.push(parsed_line); + } + } else { + // Single line with horizontal scroll support + if !self.logs_wrap && display_line.len() > content_width { + // Apply horizontal scroll offset + let start = self.logs_horizontal_scroll.min(display_line.len()); + let end = (start + content_width).min(display_line.len()); + let slice = &display_line[start..end]; + + // Add indicators for horizontal scroll + let left_indicator = if self.logs_horizontal_scroll > 0 { + "< " + } else { + "" + }; + let right_indicator = if end < display_line.len() { " >" } else { "" }; + + let visible_line = + format!("{}{}{}", left_indicator, slice, right_indicator); + let mut scrolled_line = self.parse_ansi_line(&visible_line); + + if actual_idx == self.selected_index { + for span in &mut scrolled_line.spans { + span.style = + span.style.bg(Color::DarkGray).add_modifier(Modifier::BOLD); + } + } + lines.push(scrolled_line); + } else { + lines.push(parsed_line); + } + } + } + + // Add stream status indicator to title + let mut title_parts = vec![]; + + // Add base title + title_parts.push(page_title); + + // Add stream status + let status_str = match &self.stream_status { + StreamStatus::Streaming if !self.stream_paused => " ● LIVE", + StreamStatus::Streaming if self.stream_paused => " ⏸ PAUSED", + StreamStatus::Stopped => " ⏹ STOPPED", + StreamStatus::Error(err) => { + title_parts.push(format!(" ✗ ERROR: {}", err)); + "" + } + _ => "", + }; + if !status_str.is_empty() { + title_parts.push(status_str.to_string()); + } + + // Add settings indicators + let mut settings = vec![]; + if self.logs_follow { + settings.push("F"); + } + if self.logs_wrap { + settings.push("W"); + } + if !settings.is_empty() { + title_parts.push(format!(" [{}]", settings.join(""))); + } + + // Add filter count if search is active + if self.global_search.filter_active { + title_parts.push(format!( + " ({}/{})", + filtered_indices.len(), + display_buffer.len() + )); + } + + let title_with_status = title_parts.join(""); + + let mut logs = Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .title(title_with_status), + ); + + // Enable wrapping if configured + if self.logs_wrap { + logs = logs.wrap(ratatui::widgets::Wrap { trim: false }); + } + + frame.render_widget(logs, area); + } else { + // Non-streaming logs view (not implemented yet) + let msg = Paragraph::new("Non-streaming logs not yet implemented") + .style(Style::default().fg(Color::Yellow)) + .block(Block::default().borders(Borders::ALL).title(page_title)); + frame.render_widget(msg, area); + } + } + + fn get_rendered_page_title(&self) -> String { + // Get current page config + let page = match globals::config().pages.get(&self.current_page) { + Some(p) => p, + None => return self.current_page.clone(), // Fallback to page ID + }; + + // Render the page title with template context + let ctx = self.create_template_context(None); + let mut title = globals::template_engine() + .render_string(&page.title, &ctx) + .unwrap_or_else(|_| page.title.clone()); + + // Add search filter tag if active (but not during input) + if self.global_search.filter_active && !self.global_search.active { + let filter_display = if self.global_search.query.len() > 25 { + format!("{}...", &self.global_search.query[..22]) + } else { + self.global_search.query.clone() + }; + + let mode_indicator = if self.global_search.query.starts_with('!') { + "~/" // regex + } else { + "" // literal + }; + + title = format!("{} | 🔍 {}{}", title, mode_indicator, filter_display); + } + + title + } + + fn render_statusbar(&self, frame: &mut Frame, area: Rect) { + // Build navigation shortcuts (always shown) + let nav_shortcuts = if self.stream_active && !self.logs_wrap { + "j/k: Scroll | h/l: Side-scroll | g/G: Top/Bottom | /: Search | f: LIVE/Pause | w: Wrap | ESC: Back | q: Quit" + } else if self.stream_active { + "j/k: Scroll | g/G: Top/Bottom | /: Search | f: LIVE/Pause | w: Wrap | ESC: Back | q: Quit" + } else if self.current_data.is_empty() { + "q/ESC: Quit | r: Refresh" + } else { + "j/k: Move | g/G: Top/Bottom | Enter: Select | ESC: Back | r: Refresh | q: Quit" + }; + + let row_info = if self.stream_active { + // Use frozen snapshot size when paused, otherwise use live buffer + let buffer_len = if self.stream_paused && !self.stream_frozen_snapshot.is_empty() { + self.stream_frozen_snapshot.len() + } else { + self.stream_buffer.len() + }; + format!( + "Lines: {} | Line {}/{}", + buffer_len, + self.selected_index + 1, + buffer_len + ) + } else if self.global_search.filter_active { + format!( + "Filtered: {}/{} | Row {}/{}", + self.filtered_data.len(), + self.current_data.len(), + self.selected_index + 1, + self.filtered_data.len() + ) + } else { + format!( + "Row {}/{}", + self.selected_index + 1, + self.filtered_data.len() + ) + }; + + let nav_line = Line::from(vec![ + Span::styled( + row_info, + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" | "), + Span::styled(nav_shortcuts, Style::default().fg(Color::White)), + ]); + + // Build action shortcuts (if available) + let action_line = if self.action_mode { + // In action mode: show available actions + if let Some(page) = globals::config().pages.get(&self.current_page) { + if let Some(actions) = &page.actions { + if !actions.is_empty() { + let action_shortcuts: Vec = actions + .iter() + .flat_map(|a| { + vec![ + Span::styled( + format!("{}", a.key), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(format!(": {} ", a.name)), + ] + }) + .collect(); + + let mut spans = vec![Span::styled( + "ACTION MODE - Select: ", + Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )]; + spans.extend(action_shortcuts); + spans.push(Span::styled( + " [ESC to cancel]", + Style::default().fg(Color::DarkGray), + )); + Line::from(spans) + } else { + Line::from("") + } + } else { + Line::from("") + } + } else { + Line::from("") + } + } else if let Some(page) = globals::config().pages.get(&self.current_page) { + // Normal mode: show hint to press 'a' + if let Some(actions) = &page.actions { + if !actions.is_empty() { + Line::from(vec![ + Span::styled("Press ", Style::default().fg(Color::DarkGray)), + Span::styled( + "a", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" for actions", Style::default().fg(Color::DarkGray)), + ]) + } else { + Line::from("") + } + } else { + Line::from("") + } + } else { + Line::from("") + }; + + let status = Paragraph::new(vec![nav_line, action_line]) + .style(Style::default().fg(Color::White)) + .block(Block::default().borders(Borders::ALL).title("Status")); + + frame.render_widget(status, area); + } + + fn render_action_message(&self, frame: &mut Frame, area: Rect, msg: &ActionMessage) { + use ratatui::layout::Alignment; + use ratatui::widgets::Clear; + + let (color, icon, title) = match msg.message_type { + MessageType::Success => (Color::Green, "✓", "Success"), + MessageType::Error => (Color::Red, "✗", "Error"), + MessageType::Info => (Color::Blue, "ℹ", "Info"), + MessageType::Warning => (Color::Yellow, "⚠", "Warning"), + }; + + // Calculate dynamic width based on message length + let icon_title_len = icon.chars().count() + 1 + title.len(); // icon + space + title + let message_len = msg.message.chars().count(); + let max_line_len = icon_title_len.max(message_len); + + // Dynamic width: fit content + borders (2) + padding (4) + let content_width = max_line_len.min(60); // Max 60 chars for readability + let msg_width = (content_width + 6) as u16; // +6 for borders and padding + + // Word wrap the message to fit within the width + let wrapped_lines = Self::wrap_text(&msg.message, content_width); + + // Calculate height based on wrapped lines + // icon line + spacing + message lines + padding + borders + let content_height = wrapped_lines.len() as u16; + let msg_height = (content_height + 6).min(area.height.saturating_sub(2)); // +6 for icon, spacing, padding, borders + + // Position at top-right corner + let msg_x = area.width.saturating_sub(msg_width + 1); // 1 char padding from right edge + let msg_y = 1; // 1 char from top + + let msg_area = Rect { + x: msg_x, + y: msg_y, + width: msg_width, + height: msg_height, + }; + + // Clear the background area to hide content behind + frame.render_widget(Clear, msg_area); + + // Build the message text with wrapped lines and icon + let mut message_lines = vec![Line::from("")]; // Top padding + + // Add icon line + message_lines.push(Line::from(Span::styled( + format!("{} {}", icon, title), + Style::default().fg(color).add_modifier(Modifier::BOLD), + ))); + message_lines.push(Line::from("")); // Spacing + + // Add message lines + for line in wrapped_lines { + message_lines.push(Line::from(Span::styled( + line, + Style::default().fg(Color::White), + ))); + } + message_lines.push(Line::from("")); // Bottom padding + + let message_box = Paragraph::new(message_lines) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(color).add_modifier(Modifier::BOLD)) + .style(Style::default().bg(Color::Black)), + ) + .alignment(Alignment::Left) + .wrap(ratatui::widgets::Wrap { trim: true }); + + frame.render_widget(message_box, msg_area); + } + + fn wrap_text(text: &str, max_width: usize) -> Vec { + let mut lines = Vec::new(); + let words: Vec<&str> = text.split_whitespace().collect(); + let mut current_line = String::new(); + + for word in words { + // If the word itself is longer than max_width, split it + if word.len() > max_width { + if !current_line.is_empty() { + lines.push(current_line.clone()); + current_line.clear(); + } + // Split long word into chunks + for chunk in word.chars().collect::>().chunks(max_width) { + lines.push(chunk.iter().collect()); + } + continue; + } + + let potential_line = if current_line.is_empty() { + word.to_string() + } else { + format!("{} {}", current_line, word) + }; + + if potential_line.len() <= max_width { + current_line = potential_line; + } else { + if !current_line.is_empty() { + lines.push(current_line); + } + current_line = word.to_string(); + } + } + + if !current_line.is_empty() { + lines.push(current_line); + } + + // Return at least one line even if empty + if lines.is_empty() { + lines.push(String::new()); + } + + lines + } + + fn render_action_confirm(&self, frame: &mut Frame, area: Rect, confirm: &ActionConfirm) { + use ratatui::layout::Alignment; + use ratatui::widgets::Clear; + + // Create a centered popup + let popup_width = 60.min(area.width.saturating_sub(4)); + let popup_height = 9; + let popup_x = (area.width.saturating_sub(popup_width)) / 2; + let popup_y = (area.height.saturating_sub(popup_height)) / 2; + + let popup_area = Rect { + x: popup_x, + y: popup_y, + width: popup_width, + height: popup_height, + }; + + // Clear the background area to hide content behind + frame.render_widget(Clear, popup_area); + + // Render the confirmation dialog + let dialog_text = vec![ + Line::from(""), + Line::from(Span::styled( + &confirm.message, + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + format!("Action: {}", confirm.action.name), + Style::default().fg(Color::Cyan), + )), + Line::from(""), + Line::from(Span::raw("Press 'y' to confirm, 'n' or ESC to cancel")), + Line::from(""), + ]; + + let dialog = Paragraph::new(dialog_text) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)) + .style(Style::default().bg(Color::Black)) + .title("Confirm Action"), + ) + .alignment(Alignment::Center); + + frame.render_widget(dialog, popup_area); + } + + fn render_quit_confirm(&self, frame: &mut Frame, area: Rect) { + use ratatui::layout::Alignment; + use ratatui::widgets::Clear; + + // Create a centered popup + let popup_width = 50; + let popup_height = 7; + let popup_x = (area.width.saturating_sub(popup_width)) / 2; + let popup_y = (area.height.saturating_sub(popup_height)) / 2; + + let popup_area = Rect { + x: popup_x, + y: popup_y, + width: popup_width, + height: popup_height, + }; + + // Clear the background area to hide content behind + frame.render_widget(Clear, popup_area); + + // Render the confirmation dialog + let dialog_text = vec![ + Line::from(""), + Line::from(Span::styled( + "Quit TermStack?", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::raw("Press 'y' to quit, 'n' or ESC to cancel")), + Line::from(""), + ]; + + let dialog = Paragraph::new(dialog_text) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)) + .style(Style::default().bg(Color::Black)) + .title("Confirm"), + ) + .alignment(Alignment::Center); + + frame.render_widget(dialog, popup_area); + } + + fn apply_sort_and_filter(&mut self) { + // Start with all data + let mut data = self.current_data.clone(); + + // Apply global search filter if active + if self.global_search.filter_active { + data = self.filter_data(&data); + } + + // Apply sorting if configured + if let Some(page) = globals::config().pages.get(&self.current_page) { + if let ConfigView::Table(table_view) = &page.view { + if let Some(sort_config) = &table_view.sort { + data = self.sort_data(&data, sort_config); + } + } + } + + self.filtered_data = data; + } + + fn filter_data(&self, data: &[Value]) -> Vec { + data.iter() + .filter(|item| { + // Convert item to searchable string + let item_text = self.item_to_searchable_text(item); + // Use global search to match + self.global_search.matches(&item_text) + }) + .cloned() + .collect() + } + + fn item_to_searchable_text(&self, item: &Value) -> String { + match item { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Array(arr) => arr + .iter() + .map(|v| self.item_to_searchable_text(v)) + .collect::>() + .join(" "), + Value::Object(map) => map + .values() + .map(|v| self.item_to_searchable_text(v)) + .collect::>() + .join(" "), + Value::Null => String::new(), + } + } + + fn sort_data( + &self, + data: &[Value], + sort_config: &crate::config::schema::TableSort, + ) -> Vec { + use crate::config::schema::SortOrder; + use crate::data::JsonPathExtractor; + + let mut sorted = data.to_vec(); + + // Create extractor once for efficiency + let extractor = match JsonPathExtractor::new(&sort_config.column) { + Ok(ext) => ext, + Err(_) => return sorted, // Return unsorted if path is invalid + }; + + sorted.sort_by(|a, b| { + let a_val = extractor.extract_single(a); + let b_val = extractor.extract_single(b); + + let cmp = match (&a_val, &b_val) { + (Ok(Some(av)), Ok(Some(bv))) => Self::compare_values(av, bv), + (Ok(Some(_)), Ok(None)) => std::cmp::Ordering::Less, + (Ok(None), Ok(Some(_))) => std::cmp::Ordering::Greater, + _ => std::cmp::Ordering::Equal, + }; + + match sort_config.order { + SortOrder::Asc => cmp, + SortOrder::Desc => cmp.reverse(), + } + }); + + sorted + } + + fn compare_values(a: &Value, b: &Value) -> std::cmp::Ordering { + use std::cmp::Ordering; + + match (a, b) { + (Value::String(a), Value::String(b)) => a.cmp(b), + (Value::Number(a), Value::Number(b)) => { + if let (Some(a_f), Some(b_f)) = (a.as_f64(), b.as_f64()) { + a_f.partial_cmp(&b_f).unwrap_or(Ordering::Equal) + } else { + Ordering::Equal + } + } + (Value::Bool(a), Value::Bool(b)) => a.cmp(b), + (Value::Null, Value::Null) => Ordering::Equal, + (Value::Null, _) => Ordering::Less, + (_, Value::Null) => Ordering::Greater, + _ => value_to_string(a).cmp(&value_to_string(b)), + } + } +} + +fn value_to_string(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => "null".to_string(), + Value::Array(arr) => format!("[{} items]", arr.len()), + Value::Object(_) => "{...}".to_string(), + } +} diff --git a/src/config/defaults.rs b/src/config/defaults.rs new file mode 100644 index 0000000..eb1f29c --- /dev/null +++ b/src/config/defaults.rs @@ -0,0 +1,68 @@ +use std::collections::HashMap; + +/// Default keybindings for the application +pub fn default_keybindings() -> HashMap { + let mut bindings = HashMap::new(); + + // Global + bindings.insert("q".to_string(), "quit".to_string()); + bindings.insert("?".to_string(), "help".to_string()); + bindings.insert("Esc".to_string(), "back".to_string()); + bindings.insert("Ctrl+c".to_string(), "quit".to_string()); + + // Navigation + bindings.insert("j".to_string(), "down".to_string()); + bindings.insert("k".to_string(), "up".to_string()); + bindings.insert("g".to_string(), "top".to_string()); + bindings.insert("G".to_string(), "bottom".to_string()); + bindings.insert("Enter".to_string(), "select".to_string()); + bindings.insert("h".to_string(), "back".to_string()); + bindings.insert("l".to_string(), "forward".to_string()); + + // Actions + bindings.insert("r".to_string(), "refresh".to_string()); + bindings.insert("/".to_string(), "search".to_string()); + bindings.insert(":".to_string(), "command".to_string()); + bindings.insert("y".to_string(), "yaml_view".to_string()); + + // Table specific + bindings.insert("Space".to_string(), "toggle_select".to_string()); + bindings.insert("a".to_string(), "select_all".to_string()); + bindings.insert("s".to_string(), "sort".to_string()); + bindings.insert("S".to_string(), "sort_desc".to_string()); + + // Detail view scrolling + bindings.insert("Ctrl+d".to_string(), "page_down".to_string()); + bindings.insert("Ctrl+u".to_string(), "page_up".to_string()); + + bindings +} + +/// Default theme configuration +pub fn default_theme() -> ThemeConfig { + ThemeConfig { + name: "default".to_string(), + colors: default_colors(), + } +} + +#[derive(Debug, Clone)] +pub struct ThemeConfig { + pub name: String, + pub colors: HashMap, +} + +fn default_colors() -> HashMap { + let mut colors = HashMap::new(); + + colors.insert("fg".to_string(), "white".to_string()); + colors.insert("bg".to_string(), "black".to_string()); + colors.insert("border".to_string(), "gray".to_string()); + colors.insert("selected".to_string(), "blue".to_string()); + colors.insert("success".to_string(), "green".to_string()); + colors.insert("error".to_string(), "red".to_string()); + colors.insert("warning".to_string(), "yellow".to_string()); + colors.insert("info".to_string(), "cyan".to_string()); + + colors +} diff --git a/src/config/loader.rs b/src/config/loader.rs new file mode 100644 index 0000000..ebb9fed --- /dev/null +++ b/src/config/loader.rs @@ -0,0 +1,58 @@ +use anyhow::{Context, Result}; +use std::path::Path; + +use super::schema::Config; + +pub struct ConfigLoader; + +impl ConfigLoader { + pub fn load_from_file>(path: P) -> Result { + let content = std::fs::read_to_string(path.as_ref()) + .with_context(|| format!("Failed to read config file: {:?}", path.as_ref()))?; + + Self::load_from_string(&content) + } + + pub fn load_from_string(content: &str) -> Result { + let config: Config = + serde_yaml::from_str(content).context("Failed to parse YAML config")?; + + Ok(config) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_load_minimal_config() { + let yaml = r#" +version: v1 +app: + name: "Test App" +start: main +pages: + main: + title: "Main Page" + data: + type: cli + command: "echo" + args: ["hello"] + view: + layout: table + columns: + - path: "$.name" + display: "Name" +"#; + + let result = ConfigLoader::load_from_string(yaml); + assert!(result.is_ok()); + + let config = result.unwrap(); + assert_eq!(config.version, "v1"); + assert_eq!(config.app.name, "Test App"); + assert_eq!(config.start, "main"); + assert!(config.pages.contains_key("main")); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..16f3e4a --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,9 @@ +pub mod defaults; +pub mod loader; +pub mod schema; +pub mod validator; + +pub use defaults::*; +pub use loader::ConfigLoader; +pub use schema::*; +pub use validator::ConfigValidator; diff --git a/src/config/schema.rs b/src/config/schema.rs new file mode 100644 index 0000000..6fb8563 --- /dev/null +++ b/src/config/schema.rs @@ -0,0 +1,446 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Config { + pub version: String, + pub app: AppConfig, + #[serde(default)] + pub globals: HashMap, + #[serde(default)] + pub keybindings: Option, + pub start: String, + pub pages: HashMap, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AppConfig { + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(default = "default_theme")] + pub theme: String, + #[serde(default)] + pub refresh_interval: Option, + #[serde(default = "default_history_size")] + pub history_size: usize, +} + +fn default_theme() -> String { + "default".to_string() +} + +fn default_history_size() -> usize { + 50 +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Keybindings { + #[serde(default)] + pub global: HashMap, + #[serde(default)] + pub custom: HashMap, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Page { + pub title: String, + #[serde(default)] + pub description: Option, + pub data: DataSource, + pub view: View, + #[serde(default)] + pub next: Option, + #[serde(default)] + pub actions: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub enum DataSource { + Multi(MultiDataSource), + #[serde(with = "single_or_stream")] + SingleOrStream(SingleOrStream), +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub enum SingleOrStream { + Stream(StreamDataSource), + Single(SingleDataSource), +} + +mod single_or_stream { + use super::*; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Helper { + #[serde(rename = "type", default)] + source_type: Option, + #[serde(default)] + adapter: Option, + } + + let value = serde_json::Value::deserialize(deserializer)?; + let helper: Helper = + serde_json::from_value(value.clone()).map_err(serde::de::Error::custom)?; + + // Check if it's a stream type (either old "type: stream" or new "adapter: stream") + let is_stream = match (&helper.source_type, &helper.adapter) { + (Some(DataSourceType::Stream), _) => true, + (_, Some(adapter)) if adapter == "stream" => true, + _ => false, + }; + + if is_stream { + let stream: StreamDataSource = + serde_json::from_value(value).map_err(serde::de::Error::custom)?; + Ok(SingleOrStream::Stream(stream)) + } else { + let single: SingleDataSource = + serde_json::from_value(value).map_err(serde::de::Error::custom)?; + Ok(SingleOrStream::Single(single)) + } + } + + pub fn serialize(value: &SingleOrStream, serializer: S) -> Result + where + S: Serializer, + { + match value { + SingleOrStream::Stream(s) => s.serialize(serializer), + SingleOrStream::Single(s) => s.serialize(serializer), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SingleDataSource { + // New adapter-based approach + #[serde(default)] + pub adapter: Option, + + // Old approach (for backwards compatibility during transition) + #[serde(rename = "type", default)] + pub source_type: Option, + + // Generic config fields (used by all adapters) + #[serde(flatten)] + pub config: HashMap, + + // Common fields (kept for convenience and backwards compat) + #[serde(default)] + pub items: Option, + #[serde(default)] + pub timeout: Option, + #[serde(default)] + pub refresh_interval: Option, +} + +impl SingleDataSource { + /// Get the adapter name, falling back to source_type for backwards compatibility + pub fn get_adapter_name(&self) -> Option { + if let Some(adapter) = &self.adapter { + Some(adapter.clone()) + } else if let Some(source_type) = &self.source_type { + // Map old type to adapter name + Some(match source_type { + DataSourceType::Cli => "cli".to_string(), + DataSourceType::Http => "http".to_string(), + DataSourceType::Stream => "stream".to_string(), + }) + } else { + None + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MultiDataSource { + pub sources: Vec, + #[serde(default)] + pub merge: bool, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct NamedDataSource { + pub id: String, + #[serde(flatten)] + pub source: SingleDataSource, + #[serde(default)] + pub optional: bool, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct StreamDataSource { + #[serde(rename = "type")] + pub source_type: DataSourceType, + + // CLI streaming fields + #[serde(default)] + pub command: Option, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub shell: bool, + #[serde(default)] + pub working_dir: Option, + #[serde(default)] + pub env: HashMap, + + // WebSocket streaming fields (future) + #[serde(default)] + pub websocket: Option, + + // File tailing fields (future) + #[serde(default)] + pub file: Option, + + // Stream buffer configuration + #[serde(default = "default_buffer_size")] + pub buffer_size: usize, + #[serde(default)] + pub buffer_time: Option, + #[serde(default = "default_true")] + pub follow: bool, + + // Common fields + #[serde(default)] + pub timeout: Option, +} + +fn default_buffer_size() -> usize { + 100 +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum DataSourceType { + Cli, + Http, + Stream, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "UPPERCASE")] +pub enum HttpMethod { + GET, + POST, + PUT, + DELETE, + PATCH, +} + +impl Default for HttpMethod { + fn default() -> Self { + HttpMethod::GET + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum View { + Table(TableView), + Logs(LogsView), + Text(TextView), +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TableView { + pub columns: Vec, + #[serde(default)] + pub sort: Option, + #[serde(default)] + pub group_by: Option, + #[serde(default = "default_true")] + pub selectable: bool, + #[serde(default)] + pub multi_select: bool, + #[serde(default)] + pub row_style: Vec, +} + +fn default_true() -> bool { + true +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TableColumn { + pub path: String, + pub display: String, + #[serde(default)] + pub width: Option, + #[serde(default)] + pub align: Option, + #[serde(default)] + pub transform: Option, + #[serde(default)] + pub style: Vec, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Alignment { + Left, + Center, + Right, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ConditionalStyle { + #[serde(default)] + pub condition: Option, + #[serde(default)] + pub color: Option, + #[serde(default)] + pub bold: bool, + #[serde(default)] + pub dim: bool, + #[serde(default)] + pub bg: Option, + #[serde(default)] + pub default: bool, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TableSort { + pub column: String, + #[serde(default)] + pub order: SortOrder, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SortOrder { + Asc, + Desc, +} + +impl Default for SortOrder { + fn default() -> Self { + SortOrder::Asc + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct LogsView { + #[serde(default = "default_true")] + pub follow: bool, + #[serde(default)] + pub wrap: bool, + #[serde(default)] + pub show_timestamps: bool, + #[serde(default)] + pub show_line_numbers: bool, + #[serde(default)] + pub syntax: Option, + #[serde(default)] + pub filters: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct LogFilter { + pub name: String, + pub key: String, + pub pattern: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TextView { + /// Optional: Explicitly specify the content type (yaml, json, xml, toml, etc.) + /// If not specified, will auto-detect based on content + #[serde(default)] + pub syntax: Option, + + /// Enable line numbers + #[serde(default)] + pub line_numbers: bool, + + /// Enable word wrap for long lines + #[serde(default = "default_true")] + pub wrap: bool, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub enum Navigation { + Simple(SimpleNavigation), + Conditional(Vec), +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SimpleNavigation { + pub page: String, + #[serde(default)] + pub context: HashMap, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ConditionalNavigation { + #[serde(default)] + pub condition: Option, + pub page: String, + #[serde(default)] + pub context: HashMap, + #[serde(default)] + pub default: bool, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Action { + pub key: String, + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub confirm: Option, + + // Action type (one of these should be set) + #[serde(default)] + pub command: Option, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub http: Option, + #[serde(default)] + pub script: Option, + #[serde(default)] + pub page: Option, + #[serde(default)] + pub builtin: Option, + + // Action result handling + #[serde(default)] + pub success_message: Option, + #[serde(default)] + pub error_message: Option, + #[serde(default)] + pub notification: Option, + #[serde(default)] + pub refresh: bool, + #[serde(default)] + pub context: HashMap, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct NotificationConfig { + #[serde(default)] + pub on_success: Option, + #[serde(default)] + pub on_failure: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct HttpAction { + pub method: HttpMethod, + pub url: String, + #[serde(default)] + pub headers: HashMap, + #[serde(default)] + pub body: Option, +} diff --git a/src/config/validator.rs b/src/config/validator.rs new file mode 100644 index 0000000..a2bf621 --- /dev/null +++ b/src/config/validator.rs @@ -0,0 +1,346 @@ +use anyhow::{Context, Result, anyhow}; +use std::collections::HashSet; + +use super::schema::{Config, DataSource, DataSourceType, SingleDataSource}; + +pub struct ConfigValidator; + +impl ConfigValidator { + pub fn validate(config: &Config) -> Result<()> { + // Validate version + if config.version != "v1" { + return Err(anyhow!( + "Unsupported config version: {}. Expected: v1", + config.version + )); + } + + // Validate app name + if config.app.name.trim().is_empty() { + return Err(anyhow!("App name cannot be empty")); + } + + // Validate pages exist + if config.pages.is_empty() { + return Err(anyhow!("No pages defined in config")); + } + + // Validate start page exists + if !config.pages.contains_key(&config.start) { + return Err(anyhow!("Start page '{}' not found in pages", config.start)); + } + + // Collect all page IDs for reference validation + let page_ids: HashSet<_> = config.pages.keys().cloned().collect(); + + // Validate each page + for (page_id, page) in &config.pages { + Self::validate_page(page_id, page, &page_ids) + .with_context(|| format!("Invalid page: {}", page_id))?; + } + + Ok(()) + } + + fn validate_page( + _page_id: &str, + page: &super::schema::Page, + page_ids: &HashSet, + ) -> Result<()> { + // Validate title + if page.title.trim().is_empty() { + return Err(anyhow!("Page title cannot be empty")); + } + + // Validate data source + Self::validate_data_source(&page.data).context("Invalid data source")?; + + // Validate navigation references + if let Some(nav) = &page.next { + Self::validate_navigation(nav, page_ids).context("Invalid navigation")?; + } + + // Validate actions + if let Some(actions) = &page.actions { + for (idx, action) in actions.iter().enumerate() { + Self::validate_action(action, page_ids) + .with_context(|| format!("Invalid action at index {}", idx))?; + } + } + + Ok(()) + } + + fn validate_data_source(data_source: &DataSource) -> Result<()> { + match data_source { + DataSource::SingleOrStream(super::schema::SingleOrStream::Single(single)) => { + Self::validate_single_data_source(single) + } + DataSource::SingleOrStream(super::schema::SingleOrStream::Stream(stream)) => { + Self::validate_stream_data_source(stream) + } + DataSource::Multi(multi) => { + if multi.sources.is_empty() { + return Err(anyhow!("Multi data source must have at least one source")); + } + for (idx, named_source) in multi.sources.iter().enumerate() { + Self::validate_single_data_source(&named_source.source) + .with_context(|| format!("Invalid source at index {}", idx))?; + } + Ok(()) + } + } + } + + fn validate_single_data_source(source: &SingleDataSource) -> Result<()> { + // Get adapter name (either from adapter field or legacy source_type) + let adapter_name = source + .get_adapter_name() + .ok_or_else(|| anyhow!("Data source must specify either 'adapter' or 'type' field"))?; + + // Validate adapter-specific requirements + match adapter_name.as_str() { + "cli" => { + if !source.config.contains_key("command") { + return Err(anyhow!("CLI data source must have 'command' field")); + } + } + "http" => { + if !source.config.contains_key("url") { + return Err(anyhow!("HTTP data source must have 'url' field")); + } + } + "script" => { + if !source.config.contains_key("script") { + return Err(anyhow!("Script data source must have 'script' field")); + } + } + "stream" => { + return Err(anyhow!( + "SingleDataSource cannot have adapter 'stream'. Use StreamDataSource instead." + )); + } + _ => { + // Unknown adapter - will be caught by registry at runtime + } + } + + // Validate timeout format if present + if let Some(timeout) = &source.timeout { + humantime::parse_duration(timeout) + .with_context(|| format!("Invalid timeout format: {}", timeout))?; + } + + Ok(()) + } + + fn validate_navigation( + nav: &super::schema::Navigation, + page_ids: &HashSet, + ) -> Result<()> { + match nav { + super::schema::Navigation::Simple(simple) => { + if !page_ids.contains(&simple.page) { + return Err(anyhow!("Navigation page '{}' not found", simple.page)); + } + } + super::schema::Navigation::Conditional(conditionals) => { + let mut has_default = false; + for cond in conditionals { + if !page_ids.contains(&cond.page) { + return Err(anyhow!("Navigation page '{}' not found", cond.page)); + } + if cond.default { + if has_default { + return Err(anyhow!("Multiple default navigation routes defined")); + } + has_default = true; + } + } + if !has_default { + return Err(anyhow!("Conditional navigation must have a default route")); + } + } + } + Ok(()) + } + + fn validate_action(action: &super::schema::Action, page_ids: &HashSet) -> Result<()> { + // Validate key + if action.key.is_empty() { + return Err(anyhow!("Action key cannot be empty")); + } + + // Validate name + if action.name.is_empty() { + return Err(anyhow!("Action name cannot be empty")); + } + + // Validate that at least one action type is defined + let has_command = action.command.is_some(); + let has_http = action.http.is_some(); + let has_script = action.script.is_some(); + let has_page = action.page.is_some(); + let has_builtin = action.builtin.is_some(); + + let action_count = [has_command, has_http, has_script, has_page, has_builtin] + .iter() + .filter(|&&x| x) + .count(); + + if action_count == 0 { + return Err(anyhow!( + "Action '{}' must define one of: command, http, script, page, or builtin", + action.name + )); + } + + if action_count > 1 { + return Err(anyhow!( + "Action '{}' can only define one action type", + action.name + )); + } + + // Validate page reference if present + if let Some(page) = &action.page { + if !page_ids.contains(page) { + return Err(anyhow!("Action page '{}' not found", page)); + } + } + + // Validate builtin actions + if let Some(builtin) = &action.builtin { + let valid_builtins = ["yaml_view", "help", "search", "refresh", "back", "quit"]; + if !valid_builtins.contains(&builtin.as_str()) { + return Err(anyhow!( + "Unknown builtin action: {}. Valid builtins: {:?}", + builtin, + valid_builtins + )); + } + } + + Ok(()) + } + + fn validate_stream_data_source(source: &super::schema::StreamDataSource) -> Result<()> { + match source.source_type { + DataSourceType::Stream => { + // Validate that at least one source is specified + if source.command.is_none() && source.websocket.is_none() && source.file.is_none() { + return Err(anyhow!( + "Stream data source must have 'command', 'websocket', or 'file' field" + )); + } + + // Validate CLI streaming + if source.command.is_some() && source.command.as_ref().unwrap().is_empty() { + return Err(anyhow!("Stream command cannot be empty")); + } + + // Validate buffer_size + if source.buffer_size == 0 { + return Err(anyhow!("Stream buffer_size must be greater than 0")); + } + + // Validate buffer_time format if present + if let Some(buffer_time) = &source.buffer_time { + humantime::parse_duration(buffer_time) + .with_context(|| format!("Invalid buffer_time format: {}", buffer_time))?; + } + + // Validate timeout format if present + if let Some(timeout) = &source.timeout { + humantime::parse_duration(timeout) + .with_context(|| format!("Invalid timeout format: {}", timeout))?; + } + } + _ => { + return Err(anyhow!("Stream data source must have type 'stream'")); + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::loader::ConfigLoader; + + #[test] + fn test_validate_valid_config() { + let yaml = r#" +version: v1 +app: + name: "Test App" +start: main +pages: + main: + title: "Main Page" + data: + type: cli + command: "echo" + args: ["hello"] + view: + layout: table + columns: + - path: "$.name" + display: "Name" +"#; + + let config = ConfigLoader::load_from_string(yaml).unwrap(); + let result = ConfigValidator::validate(&config); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_invalid_version() { + let yaml = r#" +version: v2 +app: + name: "Test App" +start: main +pages: + main: + title: "Main Page" + data: + type: cli + command: "echo" + view: + layout: table + columns: [] +"#; + + let config = ConfigLoader::load_from_string(yaml).unwrap(); + let result = ConfigValidator::validate(&config); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("version")); + } + + #[test] + fn test_validate_missing_start_page() { + let yaml = r#" +version: v1 +app: + name: "Test App" +start: nonexistent +pages: + main: + title: "Main Page" + data: + type: cli + command: "echo" + view: + layout: table + columns: [] +"#; + + let config = ConfigLoader::load_from_string(yaml).unwrap(); + let result = ConfigValidator::validate(&config); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Start page")); + } +} diff --git a/src/data/cli.rs b/src/data/cli.rs new file mode 100644 index 0000000..e787253 --- /dev/null +++ b/src/data/cli.rs @@ -0,0 +1,155 @@ +use async_trait::async_trait; +use serde_json::Value; +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; +use tokio::process::Command; + +use super::provider::{DataContext, DataProvider}; +use crate::error::{Result, TermStackError}; + +/// CLI command data provider +#[derive(Debug, Clone)] +pub struct CliProvider { + pub command: String, + pub args: Vec, + pub shell: bool, + pub working_dir: Option, + pub env: HashMap, + pub timeout: Duration, +} + +impl CliProvider { + pub fn new(command: String) -> Self { + Self { + command, + args: Vec::new(), + shell: false, + working_dir: None, + env: HashMap::new(), + timeout: Duration::from_secs(30), + } + } + + pub fn with_args(mut self, args: Vec) -> Self { + self.args = args; + self + } + + pub fn with_shell(mut self, shell: bool) -> Self { + self.shell = shell; + self + } + + pub fn with_working_dir(mut self, dir: PathBuf) -> Self { + self.working_dir = Some(dir); + self + } + + pub fn with_env(mut self, env: HashMap) -> Self { + self.env = env; + self + } + + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } +} + +#[async_trait] +impl DataProvider for CliProvider { + async fn fetch(&self, _context: &DataContext) -> Result { + let output = if self.shell { + // Run in shell + let shell_cmd = if cfg!(target_os = "windows") { + "cmd" + } else { + "sh" + }; + + let shell_arg = if cfg!(target_os = "windows") { + "/C" + } else { + "-c" + }; + + let full_command = format!("{} {}", self.command, self.args.join(" ")); + + let mut cmd = Command::new(shell_cmd); + cmd.arg(shell_arg).arg(full_command); + + if let Some(dir) = &self.working_dir { + cmd.current_dir(dir); + } + + for (key, value) in &self.env { + cmd.env(key, value); + } + + tokio::time::timeout(self.timeout, cmd.output()) + .await + .map_err(|_| TermStackError::DataProvider("Command timed out".to_string()))? + .map_err(|e| { + TermStackError::DataProvider(format!("Failed to execute command: {}", e)) + })? + } else { + // Direct execution + let mut cmd = Command::new(&self.command); + cmd.args(&self.args); + + if let Some(dir) = &self.working_dir { + cmd.current_dir(dir); + } + + for (key, value) in &self.env { + cmd.env(key, value); + } + + tokio::time::timeout(self.timeout, cmd.output()) + .await + .map_err(|_| TermStackError::DataProvider("Command timed out".to_string()))? + .map_err(|e| { + TermStackError::DataProvider(format!("Failed to execute command: {}", e)) + })? + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(TermStackError::DataProvider(format!( + "Command failed with status {}: {}", + output.status, stderr + ))); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Try to parse as JSON first + match serde_json::from_str(&stdout) { + Ok(json) => Ok(json), + Err(_) => { + // If JSON parsing fails, wrap the raw text as a JSON string value + // This allows text views to display raw command output (like kubectl describe, yaml, etc.) + Ok(Value::String(stdout.to_string())) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_cli_provider_echo() { + let provider = CliProvider::new("echo".to_string()) + .with_args(vec![r#"{"test": "value"}"#.to_string()]); + + let context = DataContext::new(); + let result = provider.fetch(&context).await; + + assert!(result.is_ok()); + let data = result.unwrap(); + assert_eq!(data["test"], "value"); + } +} diff --git a/src/data/http.rs b/src/data/http.rs new file mode 100644 index 0000000..9ce23fa --- /dev/null +++ b/src/data/http.rs @@ -0,0 +1,116 @@ +use async_trait::async_trait; +use reqwest::Method; +use serde_json::Value; +use std::collections::HashMap; +use std::time::Duration; + +use super::provider::{DataContext, DataProvider}; +use crate::config::HttpMethod; +use crate::error::{Result, TermStackError}; +use crate::globals; + +/// HTTP data provider +#[derive(Debug, Clone)] +pub struct HttpProvider { + pub url: String, + pub method: HttpMethod, + pub headers: HashMap, + pub body: Option, + pub timeout: Duration, +} + +impl HttpProvider { + pub fn new(url: String) -> Self { + Self { + url, + method: HttpMethod::GET, + headers: HashMap::new(), + body: None, + timeout: Duration::from_secs(30), + } + } + + pub fn with_method(mut self, method: HttpMethod) -> Self { + self.method = method; + self + } + + pub fn with_headers(mut self, headers: HashMap) -> Self { + self.headers = headers; + self + } + + pub fn with_body(mut self, body: String) -> Self { + self.body = Some(body); + self + } + + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } +} + +#[async_trait] +impl DataProvider for HttpProvider { + async fn fetch(&self, _context: &DataContext) -> Result { + let client = globals::http_client(); + + let method = match self.method { + HttpMethod::GET => Method::GET, + HttpMethod::POST => Method::POST, + HttpMethod::PUT => Method::PUT, + HttpMethod::DELETE => Method::DELETE, + HttpMethod::PATCH => Method::PATCH, + }; + + let mut request = client.request(method, &self.url); + + // Add headers + for (key, value) in &self.headers { + request = request.header(key, value); + } + + // Add body if present + if let Some(body) = &self.body { + request = request.body(body.clone()); + } + + let response = request + .send() + .await + .map_err(|e| TermStackError::DataProvider(format!("HTTP request failed: {}", e)))?; + + if !response.status().is_success() { + return Err(TermStackError::DataProvider(format!( + "HTTP request failed with status: {}", + response.status() + ))); + } + + let text = response.text().await.map_err(|e| { + TermStackError::DataProvider(format!("Failed to read response body: {}", e)) + })?; + + // Try to parse as JSON + serde_json::from_str(&text).map_err(|e| { + TermStackError::DataProvider(format!("Failed to parse response as JSON: {}", e)) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + #[ignore] // Requires network access + async fn test_http_provider_get() { + let provider = HttpProvider::new("https://httpbin.org/json".to_string()); + + let context = DataContext::new(); + let result = provider.fetch(&context).await; + + assert!(result.is_ok()); + } +} diff --git a/src/data/jsonpath.rs b/src/data/jsonpath.rs new file mode 100644 index 0000000..cac729e --- /dev/null +++ b/src/data/jsonpath.rs @@ -0,0 +1,116 @@ +use serde_json::Value; +use serde_json_path::JsonPath; + +use crate::error::{Result, TermStackError}; + +/// JSONPath extractor for filtering and selecting data +#[derive(Debug, Clone)] +pub struct JsonPathExtractor { + path: JsonPath, + path_str: String, +} + +impl JsonPathExtractor { + pub fn new(path: &str) -> Result { + // Special case for "@this" which means return the whole object + if path == "@this" { + return Ok(Self { + path: JsonPath::parse("$").map_err(|e| { + TermStackError::DataProvider(format!("Failed to parse JSONPath: {}", e)) + })?, + path_str: path.to_string(), + }); + } + + let parsed_path = JsonPath::parse(path).map_err(|e| { + TermStackError::DataProvider(format!("Failed to parse JSONPath '{}': {}", path, e)) + })?; + + Ok(Self { + path: parsed_path, + path_str: path.to_string(), + }) + } + + /// Extract array of values from data + pub fn extract(&self, data: &Value) -> Result> { + // Special handling for @this + if self.path_str == "@this" { + return match data { + Value::Array(arr) => Ok(arr.clone()), + other => Ok(vec![other.clone()]), + }; + } + + let nodes = self.path.query(data); + let values: Vec = nodes.all().into_iter().map(|v| v.clone()).collect(); + + if values.is_empty() { + // If no results, return empty array + Ok(Vec::new()) + } else { + Ok(values) + } + } + + /// Extract single value from data + pub fn extract_single(&self, data: &Value) -> Result> { + if self.path_str == "@this" { + return Ok(Some(data.clone())); + } + + let nodes = self.path.query(data); + Ok(nodes.all().first().map(|v| (*v).clone())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_extract_array() { + let data = json!({ + "items": [ + {"name": "item1"}, + {"name": "item2"} + ] + }); + + let extractor = JsonPathExtractor::new("$.items[*]").unwrap(); + let result = extractor.extract(&data).unwrap(); + + assert_eq!(result.len(), 2); + assert_eq!(result[0]["name"], "item1"); + assert_eq!(result[1]["name"], "item2"); + } + + #[test] + fn test_extract_single() { + let data = json!({ + "metadata": { + "name": "test-name" + } + }); + + let extractor = JsonPathExtractor::new("$.metadata.name").unwrap(); + let result = extractor.extract_single(&data).unwrap(); + + assert!(result.is_some()); + assert_eq!(result.unwrap(), "test-name"); + } + + #[test] + fn test_extract_at_this() { + let data = json!({ + "name": "test" + }); + + let extractor = JsonPathExtractor::new("@this").unwrap(); + let result = extractor.extract(&data).unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(result[0]["name"], "test"); + } +} diff --git a/src/data/mod.rs b/src/data/mod.rs new file mode 100644 index 0000000..73b8230 --- /dev/null +++ b/src/data/mod.rs @@ -0,0 +1,11 @@ +pub mod cli; +pub mod http; +pub mod jsonpath; +pub mod provider; +pub mod stream; + +pub use cli::CliProvider; +pub use http::HttpProvider; +pub use jsonpath::JsonPathExtractor; +pub use provider::DataProvider; +pub use stream::{StreamMessage, StreamProvider}; diff --git a/src/data/provider.rs b/src/data/provider.rs new file mode 100644 index 0000000..bbd9397 --- /dev/null +++ b/src/data/provider.rs @@ -0,0 +1,67 @@ +use async_trait::async_trait; +use serde_json::Value; +use std::collections::HashMap; + +use crate::error::Result; + +/// Context passed to data providers for variable interpolation +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct DataContext { + /// Global variables from config + pub globals: HashMap, + /// Page-specific context (selected row data from previous pages) + pub page_contexts: HashMap, +} + +impl DataContext { + pub fn new() -> Self { + Self::default() + } + + pub fn with_globals(mut self, globals: HashMap) -> Self { + self.globals = globals; + self + } + + pub fn set_page_context(&mut self, page: String, data: Value) { + self.page_contexts.insert(page, data); + } + + pub fn get_page_context(&self, page: &str) -> Option<&Value> { + self.page_contexts.get(page) + } + + pub fn get_global(&self, key: &str) -> Option<&Value> { + self.globals.get(key) + } +} + +/// Trait for data providers (CLI, HTTP, etc.) +#[async_trait] +pub trait DataProvider: Send + Sync { + /// Fetch data from the provider + async fn fetch(&self, context: &DataContext) -> Result; +} + +/// Result of data fetching with optional caching metadata +#[derive(Debug, Clone)] +pub struct DataResult { + pub data: Value, + pub cached: bool, + pub timestamp: std::time::SystemTime, +} + +impl DataResult { + pub fn new(data: Value) -> Self { + Self { + data, + cached: false, + timestamp: std::time::SystemTime::now(), + } + } + + pub fn with_cached(mut self, cached: bool) -> Self { + self.cached = cached; + self + } +} diff --git a/src/data/stream.rs b/src/data/stream.rs new file mode 100644 index 0000000..d396181 --- /dev/null +++ b/src/data/stream.rs @@ -0,0 +1,158 @@ +use crate::error::Result; +use std::process::Stdio; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; +use tokio::sync::mpsc; + +/// Messages sent from the streaming task to the main app +#[derive(Debug, Clone)] +pub enum StreamMessage { + /// New line of data received + Data(String), + /// Stream connected and started + Connected, + /// Stream ended normally + End, + /// Stream encountered an error + Error(String), +} + +/// Stream provider for CLI command streaming +pub struct StreamProvider { + command: String, + args: Vec, + shell: bool, + working_dir: Option, + env: std::collections::HashMap, +} + +impl StreamProvider { + pub fn new(command: String) -> Self { + Self { + command, + args: Vec::new(), + shell: false, + working_dir: None, + env: std::collections::HashMap::new(), + } + } + + pub fn with_args(mut self, args: Vec) -> Self { + self.args = args; + self + } + + pub fn with_shell(mut self, shell: bool) -> Self { + self.shell = shell; + self + } + + pub fn with_working_dir(mut self, dir: String) -> Self { + self.working_dir = Some(dir); + self + } + + pub fn with_env(mut self, env: std::collections::HashMap) -> Self { + self.env = env; + self + } + + /// Start streaming command output line by line + /// Returns a receiver that will get StreamMessage updates + pub fn start_stream(self) -> Result> { + let (tx, rx) = mpsc::channel(1000); // Bounded channel to prevent memory issues + + // Spawn background task to handle streaming + tokio::spawn(async move { + if let Err(e) = Self::stream_task(self, tx.clone()).await { + let _ = tx.send(StreamMessage::Error(e.to_string())).await; + } + }); + + Ok(rx) + } + + async fn stream_task(provider: StreamProvider, tx: mpsc::Sender) -> Result<()> { + // Build command + let mut cmd = if provider.shell { + let mut shell_cmd = if cfg!(target_os = "windows") { + Command::new("cmd") + } else { + Command::new("sh") + }; + + if cfg!(target_os = "windows") { + shell_cmd.arg("/C"); + } else { + shell_cmd.arg("-c"); + } + + let full_command = if provider.args.is_empty() { + provider.command + } else { + format!("{} {}", provider.command, provider.args.join(" ")) + }; + + shell_cmd.arg(full_command); + shell_cmd + } else { + let mut cmd = Command::new(&provider.command); + cmd.args(&provider.args); + cmd + }; + + // Set working directory + if let Some(working_dir) = &provider.working_dir { + cmd.current_dir(working_dir); + } + + // Set environment variables + for (key, value) in &provider.env { + cmd.env(key, value); + } + + // Configure stdio + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + // Spawn the process + let mut child = cmd.spawn()?; + + // Send connected message + let _ = tx.send(StreamMessage::Connected).await; + + // Get stdout handle + let stdout = child.stdout.take().ok_or_else(|| { + crate::error::TermStackError::DataProvider("Failed to get stdout".to_string()) + })?; + + let reader = BufReader::new(stdout); + let mut lines = reader.lines(); + + // Read lines as they come + while let Ok(Some(line)) = lines.next_line().await { + // Send line to app + if tx.send(StreamMessage::Data(line)).await.is_err() { + // Receiver dropped, kill the process + let _ = child.kill().await; + break; + } + } + + // Wait for process to finish + let status = child.wait().await?; + + if status.success() { + let _ = tx.send(StreamMessage::End).await; + } else { + let _ = tx + .send(StreamMessage::Error(format!( + "Command exited with status: {}", + status + ))) + .await; + } + + Ok(()) + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..c663476 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,39 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum TermStackError { + #[error("Config error: {0}")] + Config(String), + + #[error("Validation error: {0}")] + Validation(String), + + #[error("Data provider error: {0}")] + DataProvider(String), + + #[error("Template error: {0}")] + Template(String), + + #[error("Navigation error: {0}")] + Navigation(String), + + #[error("Action execution error: {0}")] + Action(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("YAML error: {0}")] + Yaml(#[from] serde_yaml::Error), + + #[error("{0}")] + Other(#[from] anyhow::Error), +} + +pub type Result = std::result::Result; diff --git a/src/globals.rs b/src/globals.rs new file mode 100644 index 0000000..2d74bce --- /dev/null +++ b/src/globals.rs @@ -0,0 +1,70 @@ +use crate::{config::Config, error::Result, template::TemplateEngine}; +use std::sync::OnceLock; + +/// Global configuration instance +static CONFIG: OnceLock = OnceLock::new(); + +/// Global template engine instance +static TEMPLATE_ENGINE: OnceLock = OnceLock::new(); + +/// Global HTTP client for all network requests +static HTTP_CLIENT: OnceLock = OnceLock::new(); + +/// Initialize the global configuration +/// This should be called once at application startup +pub fn init_config(config: Config) -> Result<()> { + CONFIG + .set(config) + .map_err(|_| anyhow::anyhow!("Config already initialized"))?; + Ok(()) +} + +/// Get a reference to the global configuration +/// Panics if config hasn't been initialized +pub fn config() -> &'static Config { + CONFIG + .get() + .expect("Config not initialized - call init_config first") +} + +/// Initialize the global template engine +/// This should be called once at application startup +pub fn init_template_engine() -> Result<()> { + let engine = TemplateEngine::new()?; + TEMPLATE_ENGINE + .set(engine) + .map_err(|_| anyhow::anyhow!("Template engine already initialized"))?; + Ok(()) +} + +/// Get a reference to the global template engine +/// Panics if template engine hasn't been initialized +pub fn template_engine() -> &'static TemplateEngine { + TEMPLATE_ENGINE + .get() + .expect("Template engine not initialized - call init_template_engine first") +} + +/// Get a reference to the global HTTP client +/// Lazily initialized on first access +pub fn http_client() -> &'static reqwest::Client { + HTTP_CLIENT.get_or_init(|| { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .pool_max_idle_per_host(10) + .build() + .expect("Failed to create HTTP client") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_http_client_singleton() { + let client1 = http_client(); + let client2 = http_client(); + assert!(std::ptr::eq(client1, client2)); + } +} diff --git a/src/input/command.rs b/src/input/command.rs new file mode 100644 index 0000000..82a500a --- /dev/null +++ b/src/input/command.rs @@ -0,0 +1,2 @@ +/// Command mode handler (to be implemented) +pub struct CommandMode; diff --git a/src/input/handler.rs b/src/input/handler.rs new file mode 100644 index 0000000..9f09f64 --- /dev/null +++ b/src/input/handler.rs @@ -0,0 +1,2 @@ +/// Input handler (to be implemented) +pub struct InputHandler; diff --git a/src/input/mod.rs b/src/input/mod.rs new file mode 100644 index 0000000..6c1eed3 --- /dev/null +++ b/src/input/mod.rs @@ -0,0 +1,3 @@ +pub mod command; +pub mod handler; +pub mod search; diff --git a/src/input/search.rs b/src/input/search.rs new file mode 100644 index 0000000..444cd5d --- /dev/null +++ b/src/input/search.rs @@ -0,0 +1,2 @@ +/// Search mode handler (to be implemented) +pub struct SearchMode; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..e17f388 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,16 @@ +pub mod action; +pub mod adapters; +pub mod app; +pub mod config; +pub mod data; +pub mod globals; +pub mod input; +pub mod navigation; +pub mod template; +pub mod ui; +pub mod util; +pub mod view; + +pub mod error; + +pub use error::TermStackError; diff --git a/src/main.rs b/src/main.rs index 1a8a284..860ec57 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,91 +1,96 @@ -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use ratatui::{ - DefaultTerminal, Frame, - style::Stylize, - text::Line, - widgets::{Block, Paragraph}, +use clap::Parser; +use std::path::PathBuf; + +use termstack::{ + app::App, + config::{ConfigLoader, ConfigValidator}, + globals, }; -fn main() -> color_eyre::Result<()> { - color_eyre::install()?; - let terminal = ratatui::init(); - let result = App::new().run(terminal); - ratatui::restore(); - result -} +#[derive(Parser)] +#[command(name = "termstack")] +#[command(about = "A generic TUI framework for building dashboards from YAML config", long_about = None)] +struct Cli { + /// Path to the YAML configuration file + #[arg(value_name = "CONFIG")] + config: PathBuf, + + /// Validate config and exit (don't run TUI) + #[arg(long)] + validate: bool, -/// The main application which holds the state and logic of the application. -#[derive(Debug, Default)] -pub struct App { - /// Is the application running? - running: bool, + /// Verbose output + #[arg(short, long)] + verbose: bool, } -impl App { - /// Construct a new instance of [`App`]. - pub fn new() -> Self { - Self::default() - } +#[tokio::main] +async fn main() -> color_eyre::Result<()> { + color_eyre::install()?; + + let cli = Cli::parse(); - /// Run the application's main loop. - pub fn run(mut self, mut terminal: DefaultTerminal) -> color_eyre::Result<()> { - self.running = true; - while self.running { - terminal.draw(|frame| self.render(frame))?; - self.handle_crossterm_events()?; + // Load config + println!("Loading config from: {:?}", cli.config); + let config = match ConfigLoader::load_from_file(&cli.config) { + Ok(cfg) => { + println!("✓ Config loaded successfully"); + cfg } - Ok(()) - } + Err(e) => { + eprintln!("✗ Failed to load config: {}", e); + eprintln!("\nError details: {:?}", e); + std::process::exit(1); + } + }; - /// Renders the user interface. - /// - /// This is where you add new widgets. See the following resources for more information: - /// - /// - - /// - - fn render(&mut self, frame: &mut Frame) { - let title = Line::from("Ratatui Simple Template") - .bold() - .blue() - .centered(); - let text = "Hello, Ratatui!\n\n\ - Created using https://github.com/ratatui/templates\n\ - Press `Esc`, `Ctrl-C` or `q` to stop running."; - frame.render_widget( - Paragraph::new(text) - .block(Block::bordered().title(title)) - .centered(), - frame.area(), - ) + // Validate config + println!("Validating config..."); + if let Err(e) = ConfigValidator::validate(&config) { + eprintln!("✗ Config validation failed: {}", e); + eprintln!("\nFull error chain:"); + for cause in e.chain() { + eprintln!(" - {}", cause); + } + std::process::exit(1); } + println!("✓ Config is valid"); - /// Reads the crossterm events and updates the state of [`App`]. - /// - /// If your application needs to perform work in between handling events, you can use the - /// [`event::poll`] function to check if there are any events available with a timeout. - fn handle_crossterm_events(&mut self) -> color_eyre::Result<()> { - match event::read()? { - // it's important to check KeyEventKind::Press to avoid handling key release events - Event::Key(key) if key.kind == KeyEventKind::Press => self.on_key_event(key), - Event::Mouse(_) => {} - Event::Resize(_, _) => {} - _ => {} - } - Ok(()) + // If validate-only mode, exit here + if cli.validate { + println!("\n✓ Configuration is valid!"); + return Ok(()); } - /// Handles the key events and updates the state of [`App`]. - fn on_key_event(&mut self, key: KeyEvent) { - match (key.modifiers, key.code) { - (_, KeyCode::Esc | KeyCode::Char('q')) - | (KeyModifiers::CONTROL, KeyCode::Char('c') | KeyCode::Char('C')) => self.quit(), - // Add other key handlers here. - _ => {} + // Initialize globals + globals::init_config(config.clone()) + .map_err(|e| color_eyre::eyre::eyre!("Failed to initialize config: {}", e))?; + globals::init_template_engine() + .map_err(|e| color_eyre::eyre::eyre!("Failed to initialize template engine: {}", e))?; + + // Show config summary + if cli.verbose { + println!("\nConfig Summary:"); + println!(" App: {}", config.app.name); + println!(" Start page: {}", config.start); + println!(" Pages: {}", config.pages.len()); + for (page_id, page) in &config.pages { + println!(" - {} ({})", page_id, page.title); } + println!(); } - /// Set running to false to quit the application. - fn quit(&mut self) { - self.running = false; - } + // Initialize adapter registry with default adapters + let adapter_registry = termstack::adapters::registry::AdapterRegistry::with_defaults(); + + // Run TUI + println!("Starting TUI...\n"); + let terminal = ratatui::init(); + let app = App::new(config, adapter_registry).map_err(|e| color_eyre::eyre::eyre!("{}", e))?; + let result = app + .run(terminal) + .await + .map_err(|e| color_eyre::eyre::eyre!("{}", e)); + ratatui::restore(); + result } diff --git a/src/navigation/context.rs b/src/navigation/context.rs new file mode 100644 index 0000000..5d6085c --- /dev/null +++ b/src/navigation/context.rs @@ -0,0 +1,34 @@ +use serde_json::Value; +use std::collections::HashMap; + +/// Context for navigation and template rendering +#[derive(Debug, Clone, Default)] +pub struct NavigationContext { + /// Global variables from config + pub globals: HashMap, + /// Page-specific contexts (selected row data from previous pages) + pub page_contexts: HashMap, +} + +impl NavigationContext { + pub fn new() -> Self { + Self::default() + } + + pub fn with_globals(mut self, globals: HashMap) -> Self { + self.globals = globals; + self + } + + pub fn set_page_context(&mut self, page: String, data: Value) { + self.page_contexts.insert(page, data); + } + + pub fn get_page_context(&self, page: &str) -> Option<&Value> { + self.page_contexts.get(page) + } + + pub fn get_global(&self, key: &str) -> Option<&Value> { + self.globals.get(key) + } +} diff --git a/src/navigation/mod.rs b/src/navigation/mod.rs new file mode 100644 index 0000000..a2480f1 --- /dev/null +++ b/src/navigation/mod.rs @@ -0,0 +1,7 @@ +pub mod context; +pub mod router; +pub mod stack; + +pub use context::NavigationContext; +pub use router::Router; +pub use stack::{NavigationFrame, NavigationStack}; diff --git a/src/navigation/router.rs b/src/navigation/router.rs new file mode 100644 index 0000000..c4e1c01 --- /dev/null +++ b/src/navigation/router.rs @@ -0,0 +1,26 @@ +use std::sync::Arc; + +use crate::config::Config; +use crate::error::Result; + +/// Router for resolving page navigation +#[derive(Debug, Clone)] +pub struct Router { + config: Arc, +} + +impl Router { + pub fn new(config: Arc) -> Self { + Self { config } + } + + pub fn get_page(&self, page_id: &str) -> Result<&crate::config::Page> { + self.config.pages.get(page_id).ok_or_else(|| { + crate::error::TermStackError::Navigation(format!("Page not found: {}", page_id)) + }) + } + + pub fn start_page(&self) -> &str { + &self.config.start + } +} diff --git a/src/navigation/stack.rs b/src/navigation/stack.rs new file mode 100644 index 0000000..622781b --- /dev/null +++ b/src/navigation/stack.rs @@ -0,0 +1,75 @@ +use serde_json::Value; +use std::collections::HashMap; + +/// A single frame in the navigation stack +#[derive(Debug, Clone)] +pub struct NavigationFrame { + pub page_id: String, + pub context: HashMap, + pub scroll_offset: usize, + pub selected_index: usize, +} + +impl NavigationFrame { + pub fn new(page_id: String) -> Self { + Self { + page_id, + context: HashMap::new(), + scroll_offset: 0, + selected_index: 0, + } + } +} + +/// Navigation stack for managing page history +#[derive(Debug, Clone)] +pub struct NavigationStack { + frames: Vec, + max_size: usize, +} + +impl NavigationStack { + pub fn new(max_size: usize) -> Self { + Self { + frames: Vec::new(), + max_size, + } + } + + pub fn push(&mut self, frame: NavigationFrame) { + if self.frames.len() >= self.max_size { + self.frames.remove(0); + } + self.frames.push(frame); + } + + pub fn pop(&mut self) -> Option { + self.frames.pop() + } + + pub fn current(&self) -> Option<&NavigationFrame> { + self.frames.last() + } + + pub fn current_mut(&mut self) -> Option<&mut NavigationFrame> { + self.frames.last_mut() + } + + pub fn len(&self) -> usize { + self.frames.len() + } + + pub fn is_empty(&self) -> bool { + self.frames.is_empty() + } + + pub fn frames(&self) -> &[NavigationFrame] { + &self.frames + } +} + +impl Default for NavigationStack { + fn default() -> Self { + Self::new(50) + } +} diff --git a/src/template/engine.rs b/src/template/engine.rs new file mode 100644 index 0000000..728667f --- /dev/null +++ b/src/template/engine.rs @@ -0,0 +1,179 @@ +use serde_json::Value; +use std::collections::HashMap; +use tera::{Context, Tera}; + +use super::filters; +use crate::error::{Result, TermStackError}; + +/// Template engine for rendering dynamic content +#[derive(Debug, Clone)] +pub struct TemplateEngine { + tera: Tera, +} + +impl TemplateEngine { + pub fn new() -> Result { + let mut tera = Tera::default(); + + // Register custom filters + tera.register_filter("timeago", filters::timeago); + tera.register_filter("filesizeformat", filters::filesizeformat); + tera.register_filter("status_color", filters::status_color); + + Ok(Self { tera }) + } + + /// Render a template string with the given context + pub fn render_string(&self, template: &str, context: &TemplateContext) -> Result { + let tera_context = context.to_tera_context(); + + // Clone tera for rendering since render_str requires &mut self + let mut tera = self.tera.clone(); + tera.render_str(template, &tera_context) + .map_err(|e| TermStackError::Template(format!("Template rendering error: {}", e))) + } + + /// Render a template and parse result as JSON value + pub fn render_value(&self, template: &str, context: &TemplateContext) -> Result { + let rendered = self.render_string(template, context)?; + + serde_json::from_str(&rendered).map_err(|e| { + TermStackError::Template(format!("Failed to parse rendered template as JSON: {}", e)) + }) + } + + /// Check if a string contains template syntax + pub fn is_template(s: &str) -> bool { + s.contains("{{") && s.contains("}}") + } +} + +impl Default for TemplateEngine { + fn default() -> Self { + Self::new().expect("Failed to create default template engine") + } +} + +/// Context for template rendering +#[derive(Debug, Clone)] +pub struct TemplateContext { + /// Global variables from config + pub globals: HashMap, + /// Page-specific contexts (previous page data) + pub page_contexts: HashMap, + /// Current row data (for inline rendering) + pub current: Option, +} + +impl TemplateContext { + pub fn new() -> Self { + Self { + globals: HashMap::new(), + page_contexts: HashMap::new(), + current: None, + } + } + + pub fn with_globals(mut self, globals: HashMap) -> Self { + self.globals = globals; + self + } + + pub fn with_page_context(mut self, page: String, data: Value) -> Self { + self.page_contexts.insert(page, data); + self + } + + pub fn with_current(mut self, current: Value) -> Self { + self.current = Some(current); + self + } + + /// Convert to Tera context + pub fn to_tera_context(&self) -> Context { + let mut context = Context::new(); + + // Add globals + for (key, value) in &self.globals { + context.insert(key, value); + } + + // Add page contexts + for (page, data) in &self.page_contexts { + context.insert(page, data); + } + + // Add current row data + if let Some(current) = &self.current { + context.insert("row", current); + + // Only insert "value" if not already set by page_contexts + if !context.contains_key("value") { + context.insert("value", current); + } + + // Also flatten current object fields to top level for convenience + if let Value::Object(map) = current { + for (key, value) in map { + if !context.contains_key(key) { + context.insert(key, value); + } + } + } + } + + context + } +} + +impl Default for TemplateContext { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_render_simple() { + let engine = TemplateEngine::new().unwrap(); + let mut context = TemplateContext::new(); + context.globals.insert("name".to_string(), json!("World")); + + let result = engine.render_string("Hello {{ name }}!", &context).unwrap(); + assert_eq!(result, "Hello World!"); + } + + #[test] + fn test_render_with_page_context() { + let engine = TemplateEngine::new().unwrap(); + let context = + TemplateContext::new().with_page_context("pods".to_string(), json!({"name": "my-pod"})); + + let result = engine + .render_string("Pod: {{ pods.name }}", &context) + .unwrap(); + assert_eq!(result, "Pod: my-pod"); + } + + #[test] + fn test_render_with_current() { + let engine = TemplateEngine::new().unwrap(); + let context = TemplateContext::new().with_current(json!({"status": "running"})); + + let result = engine + .render_string("Status: {{ status }}", &context) + .unwrap(); + assert_eq!(result, "Status: running"); + } + + #[test] + fn test_is_template() { + assert!(TemplateEngine::is_template("{{ var }}")); + assert!(TemplateEngine::is_template("Hello {{ name }}!")); + assert!(!TemplateEngine::is_template("Just a string")); + } +} diff --git a/src/template/filters.rs b/src/template/filters.rs new file mode 100644 index 0000000..5c5da70 --- /dev/null +++ b/src/template/filters.rs @@ -0,0 +1,118 @@ +use chrono::{DateTime, Utc}; +use humansize::{BINARY, format_size}; +use serde_json::Value; +use std::collections::HashMap; +use tera::{Result as TeraResult, to_value}; + +/// Convert timestamp to "time ago" format (e.g., "2 hours ago") +pub fn timeago(value: &Value, _args: &HashMap) -> TeraResult { + let timestamp_str = value + .as_str() + .ok_or_else(|| tera::Error::msg("timeago filter expects a string timestamp"))?; + + // Parse ISO 8601 timestamp + let parsed = DateTime::parse_from_rfc3339(timestamp_str) + .or_else(|_| { + // Try parsing without timezone + timestamp_str.parse::>().map(|dt| dt.into()) + }) + .map_err(|e| tera::Error::msg(format!("Failed to parse timestamp: {}", e)))?; + + let now = Utc::now(); + let duration = now.signed_duration_since(parsed.with_timezone(&Utc)); + + let result = if duration.num_seconds() < 60 { + format!("{}s", duration.num_seconds()) + } else if duration.num_minutes() < 60 { + format!("{}m", duration.num_minutes()) + } else if duration.num_hours() < 24 { + format!("{}h", duration.num_hours()) + } else { + format!("{}d", duration.num_days()) + }; + + to_value(result).map_err(|e| tera::Error::msg(format!("Failed to convert to value: {}", e))) +} + +/// Format bytes as human-readable file size (e.g., "1.5 GB") +pub fn filesizeformat(value: &Value, _args: &HashMap) -> TeraResult { + let bytes = if let Some(n) = value.as_u64() { + n + } else if let Some(n) = value.as_i64() { + n as u64 + } else if let Some(s) = value.as_str() { + s.parse::() + .map_err(|e| tera::Error::msg(format!("Failed to parse bytes: {}", e)))? + } else { + return Err(tera::Error::msg( + "filesizeformat filter expects a number or string", + )); + }; + + let result = format_size(bytes, BINARY); + + to_value(result).map_err(|e| tera::Error::msg(format!("Failed to convert to value: {}", e))) +} + +/// Map status values to color names +pub fn status_color(value: &Value, _args: &HashMap) -> TeraResult { + let status = value + .as_str() + .ok_or_else(|| tera::Error::msg("status_color filter expects a string"))? + .to_lowercase(); + + let color = match status.as_str() { + "running" | "active" | "ready" | "true" | "succeeded" | "healthy" | "ok" => "green", + "pending" | "starting" | "waiting" | "unknown" => "yellow", + "failed" | "error" | "unhealthy" | "false" | "terminated" | "crashloopbackoff" => "red", + "completed" => "blue", + _ => "white", + }; + + to_value(color).map_err(|e| tera::Error::msg(format!("Failed to convert to value: {}", e))) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_timeago() { + let timestamp = json!("2024-01-01T12:00:00Z"); + let result = timeago(×tamp, &HashMap::new()); + assert!(result.is_ok()); + } + + #[test] + fn test_filesizeformat() { + let bytes = json!(1536); + let result = filesizeformat(&bytes, &HashMap::new()).unwrap(); + assert_eq!(result.as_str().unwrap(), "1.50 KiB"); + } + + #[test] + fn test_status_color() { + assert_eq!( + status_color(&json!("running"), &HashMap::new()) + .unwrap() + .as_str() + .unwrap(), + "green" + ); + assert_eq!( + status_color(&json!("pending"), &HashMap::new()) + .unwrap() + .as_str() + .unwrap(), + "yellow" + ); + assert_eq!( + status_color(&json!("failed"), &HashMap::new()) + .unwrap() + .as_str() + .unwrap(), + "red" + ); + } +} diff --git a/src/template/mod.rs b/src/template/mod.rs new file mode 100644 index 0000000..993ffe7 --- /dev/null +++ b/src/template/mod.rs @@ -0,0 +1,4 @@ +pub mod engine; +pub mod filters; + +pub use engine::TemplateEngine; diff --git a/src/ui/breadcrumb.rs b/src/ui/breadcrumb.rs new file mode 100644 index 0000000..18b4884 --- /dev/null +++ b/src/ui/breadcrumb.rs @@ -0,0 +1,2 @@ +/// Breadcrumb navigation widget (to be implemented) +pub struct Breadcrumb; diff --git a/src/ui/layout.rs b/src/ui/layout.rs new file mode 100644 index 0000000..afa2da7 --- /dev/null +++ b/src/ui/layout.rs @@ -0,0 +1,2 @@ +/// Layout manager (to be implemented) +pub struct LayoutManager; diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..eb1894c --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,5 @@ +pub mod breadcrumb; +pub mod layout; +pub mod statusbar; +pub mod theme; +pub mod toast; diff --git a/src/ui/statusbar.rs b/src/ui/statusbar.rs new file mode 100644 index 0000000..5503778 --- /dev/null +++ b/src/ui/statusbar.rs @@ -0,0 +1,2 @@ +/// Status bar widget (to be implemented) +pub struct StatusBar; diff --git a/src/ui/theme.rs b/src/ui/theme.rs new file mode 100644 index 0000000..5301045 --- /dev/null +++ b/src/ui/theme.rs @@ -0,0 +1,2 @@ +/// Theme configuration (to be implemented) +pub struct Theme; diff --git a/src/ui/toast.rs b/src/ui/toast.rs new file mode 100644 index 0000000..8e0c0b7 --- /dev/null +++ b/src/ui/toast.rs @@ -0,0 +1,2 @@ +/// Toast notification manager (to be implemented) +pub struct ToastManager; diff --git a/src/util/mod.rs b/src/util/mod.rs new file mode 100644 index 0000000..4d6b25f --- /dev/null +++ b/src/util/mod.rs @@ -0,0 +1 @@ +// Utility modules (to be implemented in future phases) diff --git a/src/view/detail.rs b/src/view/detail.rs new file mode 100644 index 0000000..72182ce --- /dev/null +++ b/src/view/detail.rs @@ -0,0 +1,38 @@ +use crossterm::event::KeyEvent; +use ratatui::{Frame, layout::Rect}; +use serde_json::Value; + +use super::renderer::{ViewAction, ViewRenderer}; + +/// Detail view renderer (to be implemented) +pub struct DetailView { + data: Option, +} + +impl DetailView { + pub fn new() -> Self { + Self { data: None } + } +} + +impl Default for DetailView { + fn default() -> Self { + Self::new() + } +} + +impl ViewRenderer for DetailView { + fn render(&mut self, _frame: &mut Frame, _area: Rect, data: &[Value]) { + self.data = data.first().cloned(); + // TODO: Implement detail rendering + } + + fn handle_input(&mut self, _key: KeyEvent) -> ViewAction { + // TODO: Implement input handling + ViewAction::None + } + + fn get_selected(&self) -> Option<&Value> { + self.data.as_ref() + } +} diff --git a/src/view/help.rs b/src/view/help.rs new file mode 100644 index 0000000..51c0784 --- /dev/null +++ b/src/view/help.rs @@ -0,0 +1,35 @@ +use crossterm::event::KeyEvent; +use ratatui::{Frame, layout::Rect}; +use serde_json::Value; + +use super::renderer::{ViewAction, ViewRenderer}; + +/// Help overlay renderer (to be implemented) +pub struct HelpView; + +impl HelpView { + pub fn new() -> Self { + Self + } +} + +impl Default for HelpView { + fn default() -> Self { + Self::new() + } +} + +impl ViewRenderer for HelpView { + fn render(&mut self, _frame: &mut Frame, _area: Rect, _data: &[Value]) { + // TODO: Implement help rendering + } + + fn handle_input(&mut self, _key: KeyEvent) -> ViewAction { + // TODO: Implement input handling + ViewAction::None + } + + fn get_selected(&self) -> Option<&Value> { + None + } +} diff --git a/src/view/mod.rs b/src/view/mod.rs new file mode 100644 index 0000000..38321e8 --- /dev/null +++ b/src/view/mod.rs @@ -0,0 +1,7 @@ +pub mod detail; +pub mod help; +pub mod renderer; +pub mod table; +pub mod yaml; + +pub use renderer::{ViewAction, ViewRenderer}; diff --git a/src/view/renderer.rs b/src/view/renderer.rs new file mode 100644 index 0000000..94e1ca1 --- /dev/null +++ b/src/view/renderer.rs @@ -0,0 +1,23 @@ +use crossterm::event::KeyEvent; +use ratatui::{Frame, layout::Rect}; +use serde_json::Value; + +/// Action returned by view input handling +#[derive(Debug, Clone)] +pub enum ViewAction { + None, + Navigate, + Back, + Quit, + Search, + YamlView, + Refresh, + ExecuteAction(String), +} + +/// Trait for view renderers +pub trait ViewRenderer { + fn render(&mut self, frame: &mut Frame, area: Rect, data: &[Value]); + fn handle_input(&mut self, key: KeyEvent) -> ViewAction; + fn get_selected(&self) -> Option<&Value>; +} diff --git a/src/view/table.rs b/src/view/table.rs new file mode 100644 index 0000000..73e6893 --- /dev/null +++ b/src/view/table.rs @@ -0,0 +1,42 @@ +use crossterm::event::KeyEvent; +use ratatui::{Frame, layout::Rect}; +use serde_json::Value; + +use super::renderer::{ViewAction, ViewRenderer}; + +/// Table view renderer (to be implemented) +pub struct TableView { + rows: Vec, + selected_index: usize, +} + +impl TableView { + pub fn new() -> Self { + Self { + rows: Vec::new(), + selected_index: 0, + } + } +} + +impl Default for TableView { + fn default() -> Self { + Self::new() + } +} + +impl ViewRenderer for TableView { + fn render(&mut self, _frame: &mut Frame, _area: Rect, data: &[Value]) { + self.rows = data.to_vec(); + // TODO: Implement table rendering + } + + fn handle_input(&mut self, _key: KeyEvent) -> ViewAction { + // TODO: Implement input handling + ViewAction::None + } + + fn get_selected(&self) -> Option<&Value> { + self.rows.get(self.selected_index) + } +} diff --git a/src/view/yaml.rs b/src/view/yaml.rs new file mode 100644 index 0000000..d760a38 --- /dev/null +++ b/src/view/yaml.rs @@ -0,0 +1,38 @@ +use crossterm::event::KeyEvent; +use ratatui::{Frame, layout::Rect}; +use serde_json::Value; + +use super::renderer::{ViewAction, ViewRenderer}; + +/// YAML view renderer (to be implemented) +pub struct YamlView { + data: Option, +} + +impl YamlView { + pub fn new() -> Self { + Self { data: None } + } +} + +impl Default for YamlView { + fn default() -> Self { + Self::new() + } +} + +impl ViewRenderer for YamlView { + fn render(&mut self, _frame: &mut Frame, _area: Rect, data: &[Value]) { + self.data = data.first().cloned(); + // TODO: Implement YAML rendering + } + + fn handle_input(&mut self, _key: KeyEvent) -> ViewAction { + // TODO: Implement input handling + ViewAction::None + } + + fn get_selected(&self) -> Option<&Value> { + self.data.as_ref() + } +}