Skip to main content

max / goingson

Remove Rhai plugin runtime; native CSV import (Security axis) The plugin system existed only to run one first-party Rhai script (CSV import). That sandbox carried three serious findings — an unenforced database_write capability, no execution timeout (DoS), and no plugin provenance — for a single importer. Removing it deletes the entire attack surface instead of hardening it. - Delete crates/plugin-runtime (the Rhai engine, loader, registry, api), commands/plugin.rs, the rhai dependency, and plugins/csv-import. - Reimplement CSV/TSV import natively in commands/import.rs: parse via the csv crate, auto-detect entity type from headers, map fields to task/project/event, with the same preview/execute flow. CSV is the import interchange format; platform-specific converters live as separate tools. - Make read_import_file TOCTOU-safe and size-bounded, shared by the CSV and vCard/iCal importers (fixes S-IMP: import skipped the path/size validation export enforces). - Rename core module plugin -> import and drop the now-dead manifest types (PluginMeta, PluginType, ImportPluginConfig, ExportPluginConfig, PluginCapabilities, ImportProgress). - Rewrite the frontend import flow (choose file -> preview -> import; no plugin selection) and remove the Settings plugin manager. Fixes latent bugs in the old flow: it omitted the `input` arg wrapper and read snake_case keys, so it was broken end to end. - Update README/architecture/competition docs to drop the Rhai extensibility claim in favor of native CSV/vCard/iCal import. Resolves S-CAP, S-DOS, S-PROV by deletion; S-IMP by the shared safe reader. Workspace tests green (incl. 8 new CSV importer tests); new code clippy-clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-22 02:05 UTC
Commit: f75520064d8a49bd54987120a6907f034382c572
Parent: 278da60
30 files changed, +943 insertions, -3983 deletions
M Cargo.lock +2 -163
@@ -19,20 +19,6 @@ dependencies = [
19 19 ]
20 20
21 21 [[package]]
22 - name = "ahash"
23 - version = "0.8.12"
24 - source = "registry+https://github.com/rust-lang/crates.io-index"
25 - checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
26 - dependencies = [
27 - "cfg-if",
28 - "const-random",
29 - "getrandom 0.3.4",
30 - "once_cell",
31 - "version_check",
32 - "zerocopy",
33 - ]
34 -
35 - [[package]]
36 22 name = "aho-corasick"
37 23 version = "1.1.4"
38 24 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -695,26 +681,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
695 681 checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
696 682
697 683 [[package]]
698 - name = "const-random"
699 - version = "0.1.18"
700 - source = "registry+https://github.com/rust-lang/crates.io-index"
701 - checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
702 - dependencies = [
703 - "const-random-macro",
704 - ]
705 -
706 - [[package]]
707 - name = "const-random-macro"
708 - version = "0.1.16"
709 - source = "registry+https://github.com/rust-lang/crates.io-index"
710 - checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
711 - dependencies = [
712 - "getrandom 0.2.17",
713 - "once_cell",
714 - "tiny-keccak",
715 - ]
716 -
717 - [[package]]
718 684 name = "convert_case"
719 685 version = "0.4.0"
720 686 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -838,12 +804,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
838 804 checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
839 805
840 806 [[package]]
841 - name = "crunchy"
842 - version = "0.2.4"
843 - source = "registry+https://github.com/rust-lang/crates.io-index"
844 - checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
845 -
846 - [[package]]
847 807 name = "crypto-common"
848 808 version = "0.1.7"
849 809 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1401,7 +1361,7 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
1401 1361 dependencies = [
1402 1362 "futures-core",
1403 1363 "futures-sink",
1404 - "spin 0.9.8",
1364 + "spin",
1405 1365 ]
1406 1366
1407 1367 [[package]]
@@ -1922,7 +1882,6 @@ dependencies = [
1922 1882 "futures-util",
1923 1883 "goingson-core",
1924 1884 "goingson-db-sqlite",
1925 - "goingson-plugin-runtime",
1926 1885 "iana-time-zone",
1927 1886 "ical",
1928 1887 "icalendar",
@@ -1963,26 +1922,6 @@ dependencies = [
1963 1922 ]
1964 1923
1965 1924 [[package]]
1966 - name = "goingson-plugin-runtime"
1967 - version = "0.4.0"
1968 - dependencies = [
1969 - "async-trait",
1970 - "chrono",
1971 - "csv",
1972 - "goingson-core",
1973 - "notify",
1974 - "rhai",
1975 - "serde",
1976 - "serde_json",
1977 - "tempfile",
1978 - "thiserror 2.0.18",
1979 - "tokio",
1980 - "toml 0.8.2",
1981 - "tracing",
1982 - "uuid",
1983 - ]
1984 -
1985 - [[package]]
1986 1925 name = "gtk"
1987 1926 version = "0.18.2"
1988 1927 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2771,7 +2710,7 @@ version = "1.5.0"
2771 2710 source = "registry+https://github.com/rust-lang/crates.io-index"
2772 2711 checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
2773 2712 dependencies = [
2774 - "spin 0.9.8",
2713 + "spin",
2775 2714 ]
2776 2715
2777 2716 [[package]]
@@ -3192,15 +3131,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
3192 3131 checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
3193 3132
3194 3133 [[package]]
3195 - name = "no-std-compat"
3196 - version = "0.4.1"
3197 - source = "registry+https://github.com/rust-lang/crates.io-index"
3198 - checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
3199 - dependencies = [
3200 - "spin 0.5.2",
3201 - ]
3202 -
3203 - [[package]]
3204 3134 name = "nodrop"
3205 3135 version = "0.1.14"
3206 3136 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3502,9 +3432,6 @@ name = "once_cell"
3502 3432 version = "1.21.4"
3503 3433 source = "registry+https://github.com/rust-lang/crates.io-index"
3504 3434 checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
3505 - dependencies = [
3506 - "portable-atomic",
3507 - ]
3508 3435
3509 3436 [[package]]
3510 3437 name = "opaque-debug"
@@ -4050,12 +3977,6 @@ dependencies = [
4050 3977 ]
4051 3978
4052 3979 [[package]]
4053 - name = "portable-atomic"
4054 - version = "1.13.1"
4055 - source = "registry+https://github.com/rust-lang/crates.io-index"
4056 - checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
4057 -
4058 - [[package]]
4059 3980 name = "potential_utf"
4060 3981 version = "0.1.4"
4061 3982 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4540,36 +4461,6 @@ dependencies = [
4540 4461 ]
4541 4462
4542 4463 [[package]]
4543 - name = "rhai"
4544 - version = "1.24.0"
4545 - source = "registry+https://github.com/rust-lang/crates.io-index"
4546 - checksum = "1f9ef5dabe4c0b43d8f1187dc6beb67b53fe607fff7e30c5eb7f71b814b8c2c1"
4547 - dependencies = [
4548 - "ahash",
4549 - "bitflags 2.11.0",
4550 - "no-std-compat",
4551 - "num-traits",
4552 - "once_cell",
4553 - "rhai_codegen",
4554 - "serde",
4555 - "smallvec",
4556 - "smartstring",
4557 - "thin-vec",
4558 - "web-time",
4559 - ]
4560 -
4561 - [[package]]
4562 - name = "rhai_codegen"
4563 - version = "3.1.0"
4564 - source = "registry+https://github.com/rust-lang/crates.io-index"
4565 - checksum = "d4322a2a4e8cf30771dd9f27f7f37ca9ac8fe812dddd811096a98483080dabe6"
4566 - dependencies = [
4567 - "proc-macro2",
4568 - "quote",
4569 - "syn 2.0.117",
4570 - ]
4571 -
4572 - [[package]]
4573 4464 name = "ring"
4574 4465 version = "0.17.14"
4575 4466 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5203,18 +5094,6 @@ dependencies = [
5203 5094 ]
5204 5095
5205 5096 [[package]]
5206 - name = "smartstring"
5207 - version = "1.0.1"
5208 - source = "registry+https://github.com/rust-lang/crates.io-index"
5209 - checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
5210 - dependencies = [
5211 - "autocfg",
5212 - "serde",
5213 - "static_assertions",
5214 - "version_check",
5215 - ]
5216 -
5217 - [[package]]
5218 5097 name = "socket2"
5219 5098 version = "0.6.3"
5220 5099 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5274,12 +5153,6 @@ dependencies = [
5274 5153
5275 5154 [[package]]
5276 5155 name = "spin"
5277 - version = "0.5.2"
5278 - source = "registry+https://github.com/rust-lang/crates.io-index"
5279 - checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
5280 -
5281 - [[package]]
5282 - name = "spin"
5283 5156 version = "0.9.8"
5284 5157 source = "registry+https://github.com/rust-lang/crates.io-index"
5285 5158 checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
@@ -5500,12 +5373,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
5500 5373 checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
5501 5374
5502 5375 [[package]]
5503 - name = "static_assertions"
5504 - version = "1.1.0"
5505 - source = "registry+https://github.com/rust-lang/crates.io-index"
5506 - checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
5507 -
5508 - [[package]]
5509 5376 name = "stop-token"
5510 5377 version = "0.7.0"
5511 5378 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6216,15 +6083,6 @@ dependencies = [
6216 6083 ]
6217 6084
6218 6085 [[package]]
6219 - name = "thin-vec"
6220 - version = "0.2.14"
6221 - source = "registry+https://github.com/rust-lang/crates.io-index"
6222 - checksum = "144f754d318415ac792f9d69fc87abbbfc043ce2ef041c60f16ad828f638717d"
6223 - dependencies = [
6224 - "serde",
6225 - ]
6226 -
6227 - [[package]]
6228 6086 name = "thiserror"
6229 6087 version = "1.0.69"
6230 6088 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6305,15 +6163,6 @@ dependencies = [
6305 6163 ]
6306 6164
6307 6165 [[package]]
6308 - name = "tiny-keccak"
6309 - version = "2.0.2"
6310 - source = "registry+https://github.com/rust-lang/crates.io-index"
6311 - checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
6312 - dependencies = [
6313 - "crunchy",
6314 - ]
6315 -
6316 - [[package]]
6317 6166 name = "tinystr"
6318 6167 version = "0.8.2"
6319 6168 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7047,16 +6896,6 @@ dependencies = [
7047 6896 ]
7048 6897
7049 6898 [[package]]
7050 - name = "web-time"
7051 - version = "1.1.0"
7052 - source = "registry+https://github.com/rust-lang/crates.io-index"
7053 - checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
7054 - dependencies = [
7055 - "js-sys",
7056 - "wasm-bindgen",
7057 - ]
7058 -
7059 - [[package]]
7060 6899 name = "web_atoms"
7061 6900 version = "0.1.3"
7062 6901 source = "registry+https://github.com/rust-lang/crates.io-index"
M Cargo.toml +1 -4
@@ -2,7 +2,6 @@
2 2 members = [
3 3 "crates/core",
4 4 "crates/db-sqlite",
5 - "crates/plugin-runtime",
6 5 "src-tauri",
7 6 ]
8 7 default-members = ["src-tauri"]
@@ -61,8 +60,7 @@ tauri-plugin-window-state = "2.4.1"
61 60 tauri-plugin-updater = "2"
62 61 tauri-plugin-process = "2"
63 62
64 - # Plugin system
65 - rhai = { version = "1.17", features = ["sync", "serde"] }
63 + # Filesystem watching (db change notifications)
66 64 notify = "6.0"
67 65 notify-debouncer-mini = "0.4"
68 66 theme-common = { path = "../../MNW/shared/theme-common" }
@@ -98,4 +96,3 @@ tagtree = { path = "../../MNW/shared/tagtree" }
98 96 # Internal crates
99 97 goingson-core = { path = "crates/core" }
100 98 goingson-db-sqlite = { path = "crates/db-sqlite" }
101 - goingson-plugin-runtime = { path = "crates/plugin-runtime" }
M README.md +3 -4
@@ -50,10 +50,9 @@ The project is a Cargo workspace with three library crates and one application c
50 50 |-------|------|------|
51 51 | `goingson-core` | `crates/core/` | Domain models, repository traits, error types, business logic. No database dependency (optional sqlx feature for type derives). |
52 52 | `goingson-db-sqlite` | `crates/db-sqlite/` | SQLite persistence via sqlx. Repository implementations, FTS5 full-text search, migrations. |
53 - | `goingson-plugin-runtime` | `crates/plugin-runtime/` | Rhai scripting engine for import plugins (CSV, custom formats). File watching for hot-reload. |
54 - | `goingson-desktop` | `src-tauri/` | Tauri 2 desktop shell. Commands (thin wrappers over library crates), frontend (vanilla HTML/CSS/JS), OAuth flows, email sync, SyncKit integration. |
53 + | `goingson-desktop` | `src-tauri/` | Tauri 2 desktop shell. Commands (thin wrappers over library crates), native CSV/vCard/iCal import, frontend (vanilla HTML/CSS/JS), OAuth flows, email sync, SyncKit integration. |
55 54
56 - Dependency flow: `core` is leaf -> `db-sqlite` and `plugin-runtime` depend on `core` -> `src-tauri` depends on all three plus `synckit-client`.
55 + Dependency flow: `core` is leaf -> `db-sqlite` depends on `core` -> `src-tauri` depends on both plus `synckit-client`.
57 56
58 57 ## Features
59 58
@@ -64,7 +63,7 @@ Dependency flow: `core` is leaf -> `db-sqlite` and `plugin-runtime` depend on `c
64 63 - **Projects** -- per-project dashboards (tasks + events + emails), milestones
65 64 - **Search** -- FTS5 full-text search across all entity types
66 65 - **Cloud sync** -- SyncKit integration with E2E encryption
67 - - **Plugins** -- Rhai scripting for CSV/data import
66 + - **Import** -- native CSV/TSV import (entity type auto-detected), plus vCard and iCalendar
68 67 - **Themes** -- built-in light and dark themes with system auto-detection (see `src-tauri/frontend/themes/helix/`)
69 68 - **Keyboard shortcuts** -- vim-style navigation throughout
70 69 - **Platforms** -- macOS (primary), Windows, Linux; iOS in development
@@ -0,0 +1,8 @@
1 + //! Import data types.
2 + //!
3 + //! Typed structures produced when parsing an import file (CSV/TSV) into
4 + //! tasks, projects, or events, plus the result of executing an import.
5 +
6 + pub mod types;
7 +
8 + pub use types::*;
@@ -0,0 +1,154 @@
1 + //! Import data types.
2 + //!
3 + //! Data structures produced when parsing an import file and the result of
4 + //! executing an import.
5 +
6 + use serde::{Deserialize, Serialize};
7 +
8 + /// Result of parsing an import file.
9 + #[derive(Debug, Clone, Serialize, Deserialize)]
10 + #[serde(rename_all = "camelCase")]
11 + pub struct ImportParseResult {
12 + /// The type of entities being imported.
13 + pub entity_type: ImportEntityType,
14 + /// Parsed items ready for preview/import.
15 + pub items: Vec<ImportItem>,
16 + /// Any warnings from parsing.
17 + #[serde(default)]
18 + pub warnings: Vec<String>,
19 + }
20 +
21 + /// Type of entity being imported.
22 + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23 + #[serde(rename_all = "snake_case")]
24 + pub enum ImportEntityType {
25 + Task,
26 + Project,
27 + Event,
28 + }
29 +
30 + /// A single item to be imported.
31 + #[derive(Debug, Clone, Serialize, Deserialize)]
32 + #[serde(rename_all = "camelCase")]
33 + pub struct ImportItem {
34 + /// Row number or index from source file (for error reporting).
35 + pub source_index: usize,
36 + /// The parsed data fields.
37 + pub data: ImportItemData,
38 + /// Whether this item has validation issues.
39 + #[serde(default)]
40 + pub has_errors: bool,
41 + /// Validation error messages.
42 + #[serde(default)]
43 + pub errors: Vec<String>,
44 + }
45 +
46 + /// Parsed data for an import item.
47 + #[derive(Debug, Clone, Serialize, Deserialize)]
48 + #[serde(rename_all = "camelCase")]
49 + #[serde(tag = "type")]
50 + pub enum ImportItemData {
51 + /// Task import data.
52 + Task(ImportTaskData),
53 + /// Project import data.
54 + Project(ImportProjectData),
55 + /// Event import data.
56 + Event(ImportEventData),
57 + }
58 +
59 + /// Task data parsed from import.
60 + #[derive(Debug, Clone, Serialize, Deserialize)]
61 + #[serde(rename_all = "camelCase")]
62 + pub struct ImportTaskData {
63 + /// Task description (required).
64 + pub description: String,
65 + /// Due date in ISO format (optional).
66 + pub due: Option<String>,
67 + /// Priority: High, Medium, Low (optional).
68 + pub priority: Option<String>,
69 + /// Status: Pending, InProgress, Done (optional).
70 + pub status: Option<String>,
71 + /// Project name to associate (will be looked up or created).
72 + pub project_name: Option<String>,
73 + /// Tags as comma-separated or JSON array.
74 + pub tags: Option<Vec<String>>,
75 + /// Notes/annotations.
76 + pub notes: Option<String>,
77 + }
78 +
79 + /// Project data parsed from import.
80 + #[derive(Debug, Clone, Serialize, Deserialize)]
81 + #[serde(rename_all = "camelCase")]
82 + pub struct ImportProjectData {
83 + /// Project name (required).
84 + pub name: String,
85 + /// Description (optional).
86 + pub description: Option<String>,
87 + /// Project type (optional).
88 + pub project_type: Option<String>,
89 + /// Status (optional).
90 + pub status: Option<String>,
91 + }
92 +
93 + /// Event data parsed from import.
94 + #[derive(Debug, Clone, Serialize, Deserialize)]
95 + #[serde(rename_all = "camelCase")]
96 + pub struct ImportEventData {
97 + /// Event title (required).
98 + pub title: String,
99 + /// Start datetime in ISO format.
100 + pub start: String,
101 + /// End datetime in ISO format (optional).
102 + pub end: Option<String>,
103 + /// Location (optional).
104 + pub location: Option<String>,
105 + /// Description (optional).
106 + pub description: Option<String>,
107 + /// Project name to associate.
108 + pub project_name: Option<String>,
109 + }
110 +
111 + /// Options controlling how an import file is parsed.
112 + #[derive(Debug, Clone, Serialize, Deserialize, Default)]
113 + #[serde(rename_all = "camelCase")]
114 + pub struct ImportOptions {
115 + /// Whether the file has a header row.
116 + #[serde(default = "default_true")]
117 + pub has_header: bool,
118 + /// Field delimiter for CSV (default: comma).
119 + pub delimiter: Option<char>,
120 + /// Date format string for parsing dates.
121 + pub date_format: Option<String>,
122 + /// Additional format-specific options.
123 + #[serde(default)]
124 + pub extra: std::collections::HashMap<String, String>,
125 + }
126 +
127 + fn default_true() -> bool {
128 + true
129 + }
130 +
131 + /// Result of executing an import.
132 + #[derive(Debug, Clone, Serialize, Deserialize)]
133 + #[serde(rename_all = "camelCase")]
134 + pub struct ImportExecuteResult {
135 + /// Number of items successfully imported.
136 + pub imported_count: usize,
137 + /// Number of items that failed.
138 + pub failed_count: usize,
139 + /// Number of items skipped.
140 + pub skipped_count: usize,
141 + /// Details about failures.
142 + #[serde(default)]
143 + pub failures: Vec<ImportFailure>,
144 + }
145 +
146 + /// Details about a failed import item.
147 + #[derive(Debug, Clone, Serialize, Deserialize)]
148 + #[serde(rename_all = "camelCase")]
149 + pub struct ImportFailure {
150 + /// Source row/index.
151 + pub source_index: usize,
152 + /// Error message.
153 + pub message: String,
154 + }
@@ -42,7 +42,7 @@ pub mod error;
42 42 pub mod id_types;
43 43 pub mod models;
44 44 pub mod parser;
45 - pub mod plugin;
45 + pub mod import;
46 46 pub mod recurrence;
47 47 pub mod repository;
48 48 pub mod search_parser;
@@ -83,10 +83,9 @@ pub use day_planning::{Conflict, TimelineItem, detect_conflicts};
83 83 pub use recurrence::{calculate_next_due, calculate_next_due_in_tz, calculate_next_due_with_day, calculate_next_due_with_day_in_tz, calculate_next_due_rich, calculate_next_due_rich_in_tz, expand_recurrence, expand_recurrence_in_tz, should_recur};
84 84 pub use repository::*;
85 85 pub use urgency::calculate_urgency;
86 - pub use plugin::{
87 - ExportPluginConfig, ImportEntityType, ImportExecuteResult, ImportFailure, ImportItem,
88 - ImportItemData, ImportOptions, ImportParseResult, ImportPluginConfig, ImportProgress,
89 - ImportProjectData, ImportTaskData, ImportEventData, PluginCapabilities, PluginMeta, PluginType,
86 + pub use import::{
87 + ImportEntityType, ImportEventData, ImportExecuteResult, ImportFailure, ImportItem,
88 + ImportItemData, ImportOptions, ImportParseResult, ImportProjectData, ImportTaskData,
90 89 };
91 90 pub use email_id::deterministic_email_id;
92 91 pub use validation::Validate;
@@ -1,8 +0,0 @@
1 - //! Plugin system types and traits.
2 - //!
3 - //! This module defines the core types for the GoingsOn plugin system,
4 - //! including import/export adapters, plugin metadata, and result types.
5 -
6 - pub mod types;
7 -
8 - pub use types::*;
@@ -1,236 +0,0 @@
1 - //! Plugin system types.
2 - //!
3 - //! Defines data structures for plugin manifests, capabilities, and import/export results.
4 -
5 - use serde::{Deserialize, Serialize};
6 -
7 - /// Describes a plugin's capabilities and metadata.
8 - #[derive(Debug, Clone, Serialize, Deserialize)]
9 - #[serde(rename_all = "camelCase")]
10 - pub struct PluginMeta {
11 - /// Unique plugin identifier (directory name).
12 - pub id: String,
13 - /// Human-readable plugin name.
14 - pub name: String,
15 - /// Plugin version string.
16 - pub version: String,
17 - /// Brief description of what the plugin does.
18 - pub description: String,
19 - /// Author name or organization.
20 - pub author: String,
21 - /// Minimum app version required.
22 - pub min_app_version: Option<String>,
23 - /// Plugin type (import, export, command, hook).
24 - pub plugin_type: PluginType,
25 - /// Plugin capabilities/permissions.
26 - pub capabilities: PluginCapabilities,
27 - }
28 -
29 - /// Type of plugin functionality.
30 - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31 - #[serde(rename_all = "snake_case")]
32 - pub enum PluginType {
33 - /// Import adapter - transforms external data into GoingsOn entities.
34 - Import(ImportPluginConfig),
35 - /// Export adapter - transforms GoingsOn data to external formats.
36 - Export(ExportPluginConfig),
37 - /// Custom command - adds new functionality.
38 - Command,
39 - /// Lifecycle hook - responds to app events.
40 - Hook,
41 - }
42 -
43 - /// Configuration specific to import plugins.
44 - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45 - #[serde(rename_all = "camelCase")]
46 - pub struct ImportPluginConfig {
47 - /// File extensions this plugin can handle (e.g., ["csv", "tsv"]).
48 - pub file_extensions: Vec<String>,
49 - /// Entity types this plugin can create (e.g., ["task", "project"]).
50 - pub entity_types: Vec<String>,
51 - }
52 -
53 - /// Configuration specific to export plugins.
54 - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55 - #[serde(rename_all = "camelCase")]
56 - pub struct ExportPluginConfig {
57 - /// Output file extension (e.g., "csv").
58 - pub file_extension: String,
59 - /// Entity types this plugin can export.
60 - pub entity_types: Vec<String>,
61 - }
62 -
63 - /// Permissions granted to a plugin.
64 - #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
65 - #[serde(rename_all = "camelCase")]
66 - pub struct PluginCapabilities {
67 - /// Can read files (sandboxed to import source).
68 - #[serde(default)]
69 - pub file_read: bool,
70 - /// Can write to database.
71 - #[serde(default)]
72 - pub database_write: bool,
73 - /// Can make network requests.
74 - #[serde(default)]
75 - pub network: bool,
76 - }
77 -
78 - /// Result of parsing an import file.
79 - #[derive(Debug, Clone, Serialize, Deserialize)]
80 - #[serde(rename_all = "camelCase")]
81 - pub struct ImportParseResult {
82 - /// The type of entities being imported.
83 - pub entity_type: ImportEntityType,
84 - /// Parsed items ready for preview/import.
85 - pub items: Vec<ImportItem>,
86 - /// Any warnings from parsing.
87 - #[serde(default)]
88 - pub warnings: Vec<String>,
89 - }
90 -
91 - /// Type of entity being imported.
92 - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
93 - #[serde(rename_all = "snake_case")]
94 - pub enum ImportEntityType {
95 - Task,
96 - Project,
97 - Event,
98 - }
99 -
100 - /// A single item to be imported.
101 - #[derive(Debug, Clone, Serialize, Deserialize)]
102 - #[serde(rename_all = "camelCase")]
103 - pub struct ImportItem {
104 - /// Row number or index from source file (for error reporting).
105 - pub source_index: usize,
106 - /// The parsed data fields.
107 - pub data: ImportItemData,
108 - /// Whether this item has validation issues.
109 - #[serde(default)]
110 - pub has_errors: bool,
111 - /// Validation error messages.
112 - #[serde(default)]
113 - pub errors: Vec<String>,
114 - }
115 -
116 - /// Parsed data for an import item.
117 - #[derive(Debug, Clone, Serialize, Deserialize)]
118 - #[serde(rename_all = "camelCase")]
119 - #[serde(tag = "type")]
120 - pub enum ImportItemData {
121 - /// Task import data.
122 - Task(ImportTaskData),
123 - /// Project import data.
124 - Project(ImportProjectData),
125 - /// Event import data.
126 - Event(ImportEventData),
127 - }
128 -
129 - /// Task data parsed from import.
130 - #[derive(Debug, Clone, Serialize, Deserialize)]
131 - #[serde(rename_all = "camelCase")]
132 - pub struct ImportTaskData {
133 - /// Task description (required).
134 - pub description: String,
135 - /// Due date in ISO format (optional).
136 - pub due: Option<String>,
137 - /// Priority: High, Medium, Low (optional).
138 - pub priority: Option<String>,
139 - /// Status: Pending, InProgress, Done (optional).
140 - pub status: Option<String>,
141 - /// Project name to associate (will be looked up or created).
142 - pub project_name: Option<String>,
143 - /// Tags as comma-separated or JSON array.
144 - pub tags: Option<Vec<String>>,
145 - /// Notes/annotations.
146 - pub notes: Option<String>,
147 - }
148 -
149 - /// Project data parsed from import.
150 - #[derive(Debug, Clone, Serialize, Deserialize)]
151 - #[serde(rename_all = "camelCase")]
152 - pub struct ImportProjectData {
153 - /// Project name (required).
154 - pub name: String,
155 - /// Description (optional).
156 - pub description: Option<String>,
157 - /// Project type (optional).
158 - pub project_type: Option<String>,
159 - /// Status (optional).
160 - pub status: Option<String>,
161 - }
162 -
163 - /// Event data parsed from import.
164 - #[derive(Debug, Clone, Serialize, Deserialize)]
165 - #[serde(rename_all = "camelCase")]
166 - pub struct ImportEventData {
167 - /// Event title (required).
168 - pub title: String,
169 - /// Start datetime in ISO format.
170 - pub start: String,
171 - /// End datetime in ISO format (optional).
172 - pub end: Option<String>,
173 - /// Location (optional).
174 - pub location: Option<String>,
175 - /// Description (optional).
176 - pub description: Option<String>,
177 - /// Project name to associate.
178 - pub project_name: Option<String>,
179 - }
180 -
181 - /// Options passed to import plugin's parse function.
182 - #[derive(Debug, Clone, Serialize, Deserialize, Default)]
183 - #[serde(rename_all = "camelCase")]
184 - pub struct ImportOptions {
185 - /// Whether the file has a header row.
186 - #[serde(default = "default_true")]
187 - pub has_header: bool,
188 - /// Field delimiter for CSV (default: comma).
189 - pub delimiter: Option<char>,
190 - /// Date format string for parsing dates.
191 - pub date_format: Option<String>,
192 - /// Additional plugin-specific options.
193 - #[serde(default)]
194 - pub extra: std::collections::HashMap<String, String>,
195 - }
196 -
197 - fn default_true() -> bool {
198 - true
199 - }
200 -
201 - /// Result of executing an import.
202 - #[derive(Debug, Clone, Serialize, Deserialize)]
203 - #[serde(rename_all = "camelCase")]
204 - pub struct ImportExecuteResult {
205 - /// Number of items successfully imported.
206 - pub imported_count: usize,
207 - /// Number of items that failed.
208 - pub failed_count: usize,
209 - /// Number of items skipped.
210 - pub skipped_count: usize,
211 - /// Details about failures.
212 - #[serde(default)]
213 - pub failures: Vec<ImportFailure>,
214 - }
215 -
216 - /// Details about a failed import item.
217 - #[derive(Debug, Clone, Serialize, Deserialize)]
218 - #[serde(rename_all = "camelCase")]
219 - pub struct ImportFailure {
220 - /// Source row/index.
221 - pub source_index: usize,
222 - /// Error message.
223 - pub message: String,
224 - }
225 -
226 - /// Progress update during import.
227 - #[derive(Debug, Clone, Serialize, Deserialize)]
228 - #[serde(rename_all = "camelCase")]
229 - pub struct ImportProgress {
230 - /// Current item being processed.
231 - pub current: usize,
232 - /// Total items to process.
233 - pub total: usize,
234 - /// Status message.
235 - pub message: String,
236 - }
@@ -1,42 +0,0 @@
1 - [package]
2 - name = "goingson-plugin-runtime"
3 - version.workspace = true
4 - edition.workspace = true
5 - description = "Rhai plugin runtime for GoingsOn"
6 -
7 - [dependencies]
8 - # Plugin engine
9 - rhai = { workspace = true }
10 -
11 - # File watching for hot-reload
12 - notify = { workspace = true }
13 -
14 - # Manifest parsing
15 - toml = { workspace = true }
16 -
17 - # Core types
18 - goingson-core = { workspace = true }
19 -
20 - # Async support
21 - async-trait = { workspace = true }
22 - tokio = { workspace = true }
23 -
24 - # Serialization
25 - serde = { workspace = true }
26 - serde_json = { workspace = true }
27 -
28 - # Error handling
29 - thiserror = { workspace = true }
30 -
31 - # Utilities
32 - uuid = { workspace = true }
33 - chrono = { workspace = true }
34 -
35 - # CSV parsing
36 - csv = { workspace = true }
37 -
38 - # Logging
39 - tracing = { workspace = true }
40 -
41 - [dev-dependencies]
42 - tempfile = "3"
@@ -1,1106 +0,0 @@
1 - //! Rhai API module for plugins.
2 - //!
3 - //! Provides the `goingson::` namespace with functions for:
4 - //! - Entity creation (tasks, projects, events)
5 - //! - File I/O (sandboxed)
6 - //! - CSV/JSON parsing
7 - //! - Logging and progress reporting
8 - //! - Date parsing
9 -
10 - use rhai::plugin::*;
11 - use rhai::{Dynamic, EvalAltResult, Map, Module};
12 - use std::cell::RefCell;
13 -
14 - use goingson_core::{
15 - ImportEntityType, ImportItem, ImportItemData, ImportParseResult, ImportTaskData,
16 - ImportProjectData, ImportEventData,
17 - };
18 -
19 - // Thread-local context for plugin execution.
20 - thread_local! {
21 - static CONTEXT: RefCell<PluginApiContext> = RefCell::new(PluginApiContext::default());
22 - }
23 -
24 - /// Runtime context for plugin API calls.
25 - #[derive(Default)]
26 - pub struct PluginApiContext {
27 - /// File path being imported (for sandboxed file access).
28 - pub import_file_path: Option<String>,
29 - /// Whether file_read capability is granted.
30 - pub can_read_files: bool,
31 - /// Whether database_write capability is granted.
32 - pub can_write_db: bool,
33 - /// Collected log entries.
34 - pub logs: Vec<(String, String)>, // (level, message)
35 - /// Current progress.
36 - pub progress: Option<(usize, usize, String)>, // (current, total, message)
37 - /// Projects cache for lookup.
38 - pub projects: Vec<(String, String)>, // (id, name)
39 - }
40 -
41 - impl PluginApiContext {
42 - /// Sets the context for the current thread.
43 - #[tracing::instrument(skip_all)]
44 - pub fn set(ctx: PluginApiContext) {
45 - CONTEXT.with(|c| *c.borrow_mut() = ctx);
46 - }
47 -
48 - /// Gets a copy of the current context (used in tests).
49 - #[cfg(test)]
50 - pub fn get() -> PluginApiContext {
51 - CONTEXT.with(|c| {
52 - let ctx = c.borrow();
53 - PluginApiContext {
54 - import_file_path: ctx.import_file_path.clone(),
55 - can_read_files: ctx.can_read_files,
56 - can_write_db: ctx.can_write_db,
57 - logs: ctx.logs.clone(),
58 - progress: ctx.progress.clone(),
59 - projects: ctx.projects.clone(),
60 - }
61 - })
62 - }
63 -
64 - /// Clears the context.
65 - #[tracing::instrument(skip_all)]
66 - pub fn clear() {
67 - CONTEXT.with(|c| *c.borrow_mut() = PluginApiContext::default());
68 - }
69 - }
70 -
71 - /// API handle for plugins to interact with GoingsOn.
72 - pub struct PluginApi;
73 -
74 - impl PluginApi {
75 - /// Sets the import file path for sandboxed file access.
76 - #[tracing::instrument(skip_all)]
77 - pub fn set_import_file(path: &str) {
78 - CONTEXT.with(|c| {
79 - c.borrow_mut().import_file_path = Some(path.to_string());
80 - });
81 - }
82 -
83 - /// Sets the capabilities for the current execution.
84 - #[tracing::instrument(skip_all)]
85 - pub fn set_capabilities(file_read: bool, database_write: bool) {
86 - CONTEXT.with(|c| {
87 - let mut ctx = c.borrow_mut();
88 - ctx.can_read_files = file_read;
89 - ctx.can_write_db = database_write;
90 - });
91 - }
92 -
93 - /// Sets available projects for lookup.
94 - #[tracing::instrument(skip_all)]
95 - pub fn set_projects(projects: Vec<(String, String)>) {
96 - CONTEXT.with(|c| {
97 - c.borrow_mut().projects = projects;
98 - });
99 - }
100 -
101 - /// Gets collected log entries.
102 - #[tracing::instrument(skip_all)]
103 - pub fn get_logs() -> Vec<(String, String)> {
104 - CONTEXT.with(|c| c.borrow().logs.clone())
105 - }
106 -
107 - /// Gets current progress (used by plugin host for progress reporting).
108 - #[tracing::instrument(skip_all)]
109 - pub fn get_progress() -> Option<(usize, usize, String)> {
110 - CONTEXT.with(|c| c.borrow().progress.clone())
111 - }
112 - }
113 -
114 - /// The goingson module exported to Rhai
115 - #[export_module]
116 - mod goingson_api {
117 - use super::*;
118 -
119 - // ============ File I/O Functions ============
120 -
121 - #[rhai_fn(return_raw)]
122 - #[tracing::instrument(skip_all)]
123 - pub fn read_file(path: &str) -> Result<String, Box<EvalAltResult>> {
124 - CONTEXT.with(|c| {
125 - let ctx = c.borrow();
126 -
127 - // Check capability
128 - if !ctx.can_read_files {
129 - return Err("Permission denied: file_read capability not granted".into());
130 - }
131 -
132 - // Check if path matches the import file (sandboxing via canonical path comparison)
133 - if let Some(ref allowed) = ctx.import_file_path {
134 - let allowed_canonical = std::fs::canonicalize(allowed)
135 - .map_err(|e| format!("Cannot resolve import file path: {}", e))?;
136 - let request_canonical = std::fs::canonicalize(path)
137 - .map_err(|e| format!("Cannot resolve requested path: {}", e))?;
138 -
139 - // Allow reading the exact import file (canonical comparison prevents traversal)
140 - if allowed_canonical != request_canonical {
141 - return Err(format!(
142 - "Permission denied: can only read import file '{}'",
143 - allowed
144 - ).into());
145 - }
146 - } else {
147 - return Err("No import file set for this execution".into());
148 - }
149 -
150 - // Check file size before reading to prevent OOM on large files
151 - let metadata = std::fs::metadata(path)
152 - .map_err(|e| -> Box<EvalAltResult> { format!("Failed to stat file '{}': {}", path, e).into() })?;
153 - let max_size = 10 * 1024 * 1024; // 10 MB
154 - if metadata.len() > max_size {
155 - return Err(format!(
156 - "File '{}' is {} bytes, exceeding {} byte limit",
157 - path, metadata.len(), max_size
158 - ).into());
159 - }
160 -
161 - std::fs::read_to_string(path)
162 - .map_err(|e| format!("Failed to read file '{}': {}", path, e).into())
163 - })
164 - }
165 -
166 - #[rhai_fn(return_raw)]
167 - #[tracing::instrument(skip_all)]
168 - pub fn parse_csv(content: &str, options: Map) -> Result<rhai::Array, Box<EvalAltResult>> {
169 - // Excel-exported CSVs start with a BOM that breaks header detection
170 - let content = content.strip_prefix('\u{FEFF}').unwrap_or(content);
171 -
172 - let has_header = options
173 - .get("has_header")
174 - .and_then(|v| v.as_bool().ok())
175 - .unwrap_or(true);
176 -
177 - let delimiter = options
178 - .get("delimiter")
179 - .and_then(|v| v.clone().into_string().ok())
180 - .and_then(|s| s.chars().next())
181 - .unwrap_or(',');
182 -
183 - let mut reader = csv::ReaderBuilder::new()
184 - .has_headers(has_header)
185 - .delimiter(delimiter as u8)
186 - .flexible(true)
187 - .from_reader(content.as_bytes());
188 -
189 - // Single-pass: determine headers, then iterate records from the same reader.
190 - // For has_header=true, headers() consumes the first row and caches it.
191 - // For has_header=false, peek the first record for column count, then
192 - // process it as data along with the rest.
193 - let (headers, first_record) = if has_header {
194 - let hdrs: Vec<String> = reader
195 - .headers()
196 - .map_err(|e| -> Box<EvalAltResult> { format!("Failed to read CSV headers: {}", e).into() })?
197 - .iter()
198 - .map(|s| s.to_string())
199 - .collect();
200 - (hdrs, None)
201 - } else {
202 - // Read first record to determine column count
203 - let first = reader.records().next();
204 - match first {
205 - Some(Ok(record)) => {
206 - let hdrs: Vec<String> = (0..record.len()).map(|i| format!("col_{}", i)).collect();
207 - (hdrs, Some(record))
208 - }
209 - _ => (Vec::new(), None),
210 - }
211 - };
212 -
213 - let mut rows = rhai::Array::new();
214 -
215 - // Process the first record if we consumed it for column detection
216 - let records_iter: Box<dyn Iterator<Item = csv::Result<csv::StringRecord>>> =
217 - if let Some(first) = first_record {
218 - Box::new(std::iter::once(Ok(first)).chain(reader.records()))
219 - } else {
220 - Box::new(reader.records())
221 - };
222 -
223 - for result in records_iter {
224 - let record = result.map_err(|e| -> Box<EvalAltResult> { format!("CSV parse error: {}", e).into() })?;
225 -
226 - let mut row_map = Map::new();
227 - for (i, field) in record.iter().enumerate() {
228 - let key = headers.get(i).cloned().unwrap_or_else(|| format!("col_{}", i));
229 - row_map.insert(key.into(), Dynamic::from(field.to_string()));
230 - }
231 -
232 - rows.push(Dynamic::from_map(row_map));
233 - }
234 -
235 - Ok(rows)
236 - }
237 -
238 - #[rhai_fn(return_raw)]
239 - #[tracing::instrument(skip_all)]
240 - pub fn parse_json(content: &str) -> Result<Dynamic, Box<EvalAltResult>> {
241 - serde_json::from_str::<serde_json::Value>(content)
242 - .map(json_to_dynamic)
243 - .map_err(|e| format!("JSON parse error: {}", e).into())
244 - }
245 -
246 - // ============ Logging Functions ============
247 -
248 - #[tracing::instrument(skip_all)]
249 - pub fn log_info(message: &str) {
250 - CONTEXT.with(|c| {
251 - c.borrow_mut().logs.push(("info".to_string(), message.to_string()));
252 - });
253 - }
254 -
255 - #[tracing::instrument(skip_all)]
256 - pub fn log_warn(message: &str) {
257 - CONTEXT.with(|c| {
258 - c.borrow_mut().logs.push(("warn".to_string(), message.to_string()));
259 - });
260 - }
261 -
262 - #[tracing::instrument(skip_all)]
263 - pub fn log_error(message: &str) {
264 - CONTEXT.with(|c| {
265 - c.borrow_mut().logs.push(("error".to_string(), message.to_string()));
266 - });
267 - }
268 -
269 - // ============ Progress Functions ============
270 -
271 - #[tracing::instrument(skip_all)]
272 - pub fn report_progress(current: i64, total: i64, message: &str) {
273 - CONTEXT.with(|c| {
274 - c.borrow_mut().progress = Some((current.max(0) as usize, total.max(0) as usize, message.to_string()));
275 - });
276 - }
277 -
278 - // ============ Date Parsing ============
279 -
280 - #[rhai_fn(return_raw)]
281 - #[tracing::instrument(skip_all)]
282 - pub fn parse_date(input: &str) -> Result<String, Box<EvalAltResult>> {
283 - // Try various common date formats
284 - let formats = [
285 - "%Y-%m-%d",
286 - "%Y-%m-%dT%H:%M:%S",
287 - "%Y-%m-%dT%H:%M:%SZ",
288 - "%Y-%m-%dT%H:%M:%S%.fZ",
289 - "%Y/%m/%d",
290 - "%m/%d/%Y",
291 - "%d/%m/%Y",
292 - "%d-%m-%Y",
293 - "%B %d, %Y",
294 - "%b %d, %Y",
295 - ];
296 -
297 - let input = input.trim();
298 -
299 - // If empty, return empty (optional field)
300 - if input.is_empty() {
301 - return Ok(String::new());
302 - }
303 -
304 - for format in &formats {
305 - if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(input, format) {
306 - return Ok(dt.format("%Y-%m-%dT%H:%M:%S").to_string());
307 - }
308 - if let Ok(d) = chrono::NaiveDate::parse_from_str(input, format) {
309 - return Ok(d.format("%Y-%m-%d").to_string());
310 - }
311 - }
312 -
313 - // Try parsing as ISO 8601
314 - if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(input) {
315 - return Ok(dt.format("%Y-%m-%dT%H:%M:%S").to_string());
316 - }
317 -
318 - Err(format!("Could not parse date: '{}'", input).into())
319 - }
320 -
321 - // ============ Project Lookup ============
322 -
323 - #[tracing::instrument(skip_all)]
324 - pub fn find_project_by_name(name: &str) -> Dynamic {
325 - CONTEXT.with(|c| {
326 - let ctx = c.borrow();
327 - let name_lower = name.to_lowercase();
328 -
329 - for (id, project_name) in &ctx.projects {
330 - if project_name.to_lowercase() == name_lower {
331 - let mut map = Map::new();
332 - map.insert("id".into(), Dynamic::from(id.clone()));
333 - map.insert("name".into(), Dynamic::from(project_name.clone()));
334 - return Dynamic::from_map(map);
335 - }
336 - }
337 -
338 - Dynamic::UNIT
339 - })
340 - }
341 -
342 - #[tracing::instrument(skip_all)]
343 - pub fn list_projects() -> rhai::Array {
344 - CONTEXT.with(|c| {
345 - let ctx = c.borrow();
346 - ctx.projects
347 - .iter()
348 - .map(|(id, name)| {
349 - let mut map = Map::new();
350 - map.insert("id".into(), Dynamic::from(id.clone()));
351 - map.insert("name".into(), Dynamic::from(name.clone()));
352 - Dynamic::from_map(map)
353 - })
354 - .collect()
355 - })
356 - }
357 -
358 - // ============ Result Builders ============
359 -
360 - #[tracing::instrument(skip_all)]
361 - pub fn task_result(items: rhai::Array) -> Dynamic {
362 - let mut result = Map::new();
363 - result.insert("entity_type".into(), Dynamic::from("task"));
364 - result.insert("items".into(), Dynamic::from(items));
365 - result.insert("warnings".into(), Dynamic::from(rhai::Array::new()));
366 - Dynamic::from_map(result)
367 - }
368 -
369 - #[tracing::instrument(skip_all)]
370 - pub fn project_result(items: rhai::Array) -> Dynamic {
371 - let mut result = Map::new();
372 - result.insert("entity_type".into(), Dynamic::from("project"));
373 - result.insert("items".into(), Dynamic::from(items));
374 - result.insert("warnings".into(), Dynamic::from(rhai::Array::new()));
375 - Dynamic::from_map(result)
376 - }
377 -
378 - #[tracing::instrument(skip_all)]
379 - pub fn event_result(items: rhai::Array) -> Dynamic {
380 - let mut result = Map::new();
381 - result.insert("entity_type".into(), Dynamic::from("event"));
382 - result.insert("items".into(), Dynamic::from(items));
383 - result.insert("warnings".into(), Dynamic::from(rhai::Array::new()));
384 - Dynamic::from_map(result)
385 - }
386 - }
387 -
388 - fn json_to_dynamic(value: serde_json::Value) -> Dynamic {
389 - match value {
390 - serde_json::Value::Null => Dynamic::UNIT,
391 - serde_json::Value::Bool(b) => Dynamic::from(b),
392 - serde_json::Value::Number(n) => {
393 - if let Some(i) = n.as_i64() {
394 - Dynamic::from(i)
395 - } else if let Some(f) = n.as_f64() {
396 - Dynamic::from(f)
397 - } else {
398 - Dynamic::UNIT
399 - }
400 - }
401 - serde_json::Value::String(s) => Dynamic::from(s),
402 - serde_json::Value::Array(arr) => {
403 - Dynamic::from(arr.into_iter().map(json_to_dynamic).collect::<rhai::Array>())
404 - }
405 - serde_json::Value::Object(obj) => {
406 - let mut map = Map::new();
407 - for (k, v) in obj {
408 - map.insert(k.into(), json_to_dynamic(v));
409 - }
410 - Dynamic::from_map(map)
411 - }
412 - }
413 - }
414 -
415 - /// Creates the goingson:: module for Rhai.
416 - #[tracing::instrument(skip_all)]
417 - pub fn create_goingson_module() -> Module {
418 - exported_module!(goingson_api)
419 - }
420 -
421 - /// Converts a Rhai Dynamic parse result to our ImportParseResult type.
422 - #[tracing::instrument(skip_all)]
423 - pub fn dynamic_to_import_result(
424 - value: Dynamic,
425 - plugin_id: &str,
426 - ) -> Result<ImportParseResult, crate::error::PluginError> {
427 - let map = value.try_cast::<Map>().ok_or_else(|| {
428 - crate::error::PluginError::invalid_return(plugin_id, "parse() must return a map")
429 - })?;
430 -
431 - let entity_type_str = map
432 - .get("entity_type")
433 - .and_then(|v| v.clone().into_string().ok())
434 - .ok_or_else(|| {
435 - crate::error::PluginError::invalid_return(plugin_id, "missing entity_type field")
436 - })?;
437 -
438 - let entity_type = match entity_type_str.as_str() {
439 - "task" => ImportEntityType::Task,
440 - "project" => ImportEntityType::Project,
441 - "event" => ImportEntityType::Event,
442 - other => {
443 - return Err(crate::error::PluginError::invalid_return(
444 - plugin_id,
445 - format!("unknown entity_type: {}", other),
446 - ))
447 - }
448 - };
449 -
450 - let items_array = map
451 - .get("items")
452 - .and_then(|v| v.clone().try_cast::<rhai::Array>())
453 - .ok_or_else(|| {
454 - crate::error::PluginError::invalid_return(plugin_id, "missing items array")
455 - })?;
456 -
457 - let mut items = Vec::new();
458 - for (idx, item) in items_array.into_iter().enumerate() {
459 - let item_map = item.try_cast::<Map>().ok_or_else(|| {
460 - crate::error::PluginError::invalid_return(
461 - plugin_id,
462 - format!("item {} is not a map", idx),
463 - )
464 - })?;
465 -
466 - let import_item = map_to_import_item(&item_map, idx, &entity_type)?;
467 - items.push(import_item);
468 - }
469 -
470 - let warnings = map
471 - .get("warnings")
472 - .and_then(|v| v.clone().try_cast::<rhai::Array>())
473 - .map(|arr| {
474 - arr.into_iter()
475 - .filter_map(|v| v.into_string().ok())
476 - .collect()
477 - })
478 - .unwrap_or_default();
479 -
480 - Ok(ImportParseResult {
481 - entity_type,
482 - items,
483 - warnings,
484 - })
485 - }
486 -
487 - fn map_to_import_item(
488 - map: &Map,
489 - source_index: usize,
490 - entity_type: &ImportEntityType,
491 - ) -> Result<ImportItem, crate::error::PluginError> {
492 - let data = match entity_type {
493 - ImportEntityType::Task => {
494 - let description = map
495 - .get("description")
496 - .and_then(|v| v.clone().into_string().ok())
497 - .unwrap_or_default();
498 -
499 - ImportItemData::Task(ImportTaskData {
500 - description,
Lines truncated
@@ -1,477 +0,0 @@
1 - //! Rhai script engine with safety configuration.
2 - //!
3 - //! Provides a sandboxed Rhai engine for executing plugin scripts.
4 -
5 - use rhai::{Dynamic, Engine, AST};
6 - use std::sync::Arc;
7 -
8 - use crate::api::create_goingson_module;
9 - use crate::error::{PluginError, Result};
10 -
11 - /// Safety limits for script execution.
12 - #[derive(Debug, Clone)]
13 - pub struct SafetyLimits {
14 - /// Maximum number of operations before termination.
15 - pub max_operations: u64,
16 - /// Maximum call stack depth (recursion limit).
17 - pub max_call_levels: usize,
18 - /// Maximum string size in bytes.
19 - pub max_string_size: usize,
20 - /// Maximum array elements.
21 - pub max_array_size: usize,
22 - /// Maximum map entries.
23 - pub max_map_size: usize,
24 - }
25 -
26 - impl Default for SafetyLimits {
27 - fn default() -> Self {
28 - Self {
29 - max_operations: 100_000,
30 - max_call_levels: 32,
31 - max_string_size: 1_048_576, // 1MB
32 - max_array_size: 10_000,
33 - max_map_size: 10_000,
34 - }
35 - }
36 - }
37 -
38 - /// Plugin script engine with sandboxing.
39 - pub struct PluginEngine {
40 - engine: Engine,
41 - limits: SafetyLimits,
42 - }
43 -
44 - impl PluginEngine {
45 - /// Creates a new plugin engine with default safety limits.
46 - #[tracing::instrument(skip_all)]
47 - pub fn new() -> Self {
48 - Self::with_limits(SafetyLimits::default())
49 - }
50 -
51 - /// Creates a new plugin engine with custom safety limits.
52 - #[tracing::instrument(skip_all)]
53 - pub fn with_limits(limits: SafetyLimits) -> Self {
54 - let mut engine = Engine::new();
55 -
56 - // Apply safety limits
57 - engine.set_max_operations(limits.max_operations);
58 - engine.set_max_call_levels(limits.max_call_levels);
59 - engine.set_max_string_size(limits.max_string_size);
60 - engine.set_max_array_size(limits.max_array_size);
61 - engine.set_max_map_size(limits.max_map_size);
62 -
63 - // Disable dangerous operations
64 - engine.disable_symbol("eval");
65 -
66 - // Register the goingson:: API module.
67 - //
68 - // This is the ONLY host surface exposed to plugins: pure data helpers, no
69 - // filesystem, no network, no DB. A plugin's manifest may declare a
70 - // `network` capability, but nothing here backs it — it grants nothing
71 - // today (fail-closed by omission, ultra-fuzz Run #28 surprise).
72 - //
73 - // WARNING for future host functions: if you ever register a networking or
74 - // filesystem capability here, gate it on the plugin's manifest capability
75 - // AND force a fresh consent prompt. The reload escalation guard only fires
76 - // when a manifest *changes*; it will NOT re-prompt for an already-installed
77 - // `network = true` plugin when the host newly gains the capability, so
78 - // adding the host function alone would silently activate it.
79 - let goingson_module = create_goingson_module();
80 - engine.register_static_module("goingson", Arc::new(goingson_module));
81 -
82 - Self { engine, limits }
83 - }
84 -
85 - /// Compiles a script into an AST for caching.
86 - #[tracing::instrument(skip_all)]
87 - pub fn compile(&self, script: &str) -> Result<AST> {
88 - self.engine
89 - .compile(script)
90 - .map_err(|e| PluginError::ScriptError {
91 - plugin: "unknown".to_string(),
92 - message: e.to_string(),
93 - })
94 - }
95 -
96 - /// Compiles a script with a plugin name for error messages.
97 - #[tracing::instrument(skip_all)]
98 - pub fn compile_plugin(&self, plugin_id: &str, script: &str) -> Result<AST> {
99 - self.engine
100 - .compile(script)
101 - .map_err(|e| PluginError::script(plugin_id, e.to_string()))
102 - }
103 -
104 - /// Checks if a function exists in the compiled AST.
105 - #[tracing::instrument(skip_all)]
106 - pub fn has_function(&self, ast: &AST, name: &str, arity: usize) -> bool {
107 - ast.iter_functions()
108 - .any(|f| f.name == name && f.params.len() == arity)
109 - }
110 -
111 - /// Calls a function in the script with no arguments.
112 - #[tracing::instrument(skip_all)]
113 - pub fn call_fn<T: Clone + Send + Sync + 'static>(
114 - &self,
115 - ast: &AST,
116 - plugin_id: &str,
117 - fn_name: &str,
118 - ) -> Result<T> {
119 - self.engine
120 - .call_fn::<T>(&mut rhai::Scope::new(), ast, fn_name, ())
121 - .map_err(|e| self.map_rhai_error(plugin_id, e))
122 - }
123 -
124 - /// Calls a function with one argument.
125 - #[tracing::instrument(skip_all)]
126 - pub fn call_fn_1<A, T>(&self, ast: &AST, plugin_id: &str, fn_name: &str, arg: A) -> Result<T>
127 - where
128 - A: Clone + Send + Sync + 'static,
129 - T: Clone + Send + Sync + 'static,
130 - {
131 - self.engine
132 - .call_fn::<T>(&mut rhai::Scope::new(), ast, fn_name, (arg,))
133 - .map_err(|e| self.map_rhai_error(plugin_id, e))
134 - }
135 -
136 - /// Calls a function with two arguments.
137 - #[tracing::instrument(skip_all)]
138 - pub fn call_fn_2<A, B, T>(
139 - &self,
140 - ast: &AST,
141 - plugin_id: &str,
142 - fn_name: &str,
143 - arg1: A,
144 - arg2: B,
145 - ) -> Result<T>
146 - where
147 - A: Clone + Send + Sync + 'static,
148 - B: Clone + Send + Sync + 'static,
149 - T: Clone + Send + Sync + 'static,
150 - {
151 - self.engine
152 - .call_fn::<T>(&mut rhai::Scope::new(), ast, fn_name, (arg1, arg2))
153 - .map_err(|e| self.map_rhai_error(plugin_id, e))
154 - }
155 -
156 - /// Calls a function and returns a Dynamic result.
157 - #[tracing::instrument(skip_all)]
158 - pub fn call_fn_dynamic(
159 - &self,
160 - ast: &AST,
161 - plugin_id: &str,
162 - fn_name: &str,
163 - args: impl rhai::FuncArgs,
164 - ) -> Result<Dynamic> {
165 - self.engine
166 - .call_fn::<Dynamic>(&mut rhai::Scope::new(), ast, fn_name, args)
167 - .map_err(|e| self.map_rhai_error(plugin_id, e))
168 - }
169 -
170 - /// Maps a Rhai error to a PluginError.
171 - fn map_rhai_error(&self, plugin_id: &str, err: Box<rhai::EvalAltResult>) -> PluginError {
172 - let message = err.to_string();
173 -
174 - // Check for specific error types
175 - if message.contains("Too many operations") {
176 - PluginError::safety_limit(plugin_id, "Maximum operations exceeded")
177 - } else if message.contains("Stack overflow") {
178 - PluginError::safety_limit(plugin_id, "Maximum call depth exceeded")
179 - } else {
180 - PluginError::script(plugin_id, message)
181 - }
182 - }
183 -
184 - /// Returns the current safety limits.
185 - #[tracing::instrument(skip_all)]
186 - pub fn limits(&self) -> &SafetyLimits {
187 - &self.limits
188 - }
189 -
190 - /// Returns a reference to the underlying engine.
191 - #[tracing::instrument(skip_all)]
192 - pub fn inner(&self) -> &Engine {
193 - &self.engine
194 - }
195 - }
196 -
197 - impl Default for PluginEngine {
198 - fn default() -> Self {
199 - Self::new()
200 - }
201 - }
202 -
203 - #[cfg(test)]
204 - mod tests {
205 - use super::*;
206 -
207 - #[test]
208 - fn test_compile_valid_script() {
209 - let engine = PluginEngine::new();
210 - let result = engine.compile("fn hello() { 42 }");
211 - assert!(result.is_ok());
212 - }
213 -
214 - #[test]
215 - fn test_compile_invalid_script() {
216 - let engine = PluginEngine::new();
217 - let result = engine.compile("fn hello( { }");
218 - assert!(result.is_err());
219 - }
220 -
221 - #[test]
222 - fn test_has_function() {
223 - let engine = PluginEngine::new();
224 - let ast = engine.compile("fn describe() { } fn parse(x, y) { }").unwrap();
225 -
226 - assert!(engine.has_function(&ast, "describe", 0));
227 - assert!(engine.has_function(&ast, "parse", 2));
228 - assert!(!engine.has_function(&ast, "describe", 1));
229 - assert!(!engine.has_function(&ast, "missing", 0));
230 - }
231 -
232 - #[test]
233 - fn test_call_function() {
234 - let engine = PluginEngine::new();
235 - let ast = engine.compile("fn answer() { 42 }").unwrap();
236 -
237 - let result: i64 = engine.call_fn(&ast, "test", "answer").unwrap();
238 - assert_eq!(result, 42);
239 - }
240 -
241 - #[test]
242 - fn test_operation_limit() {
243 - let limits = SafetyLimits {
244 - max_operations: 100,
245 - ..Default::default()
246 - };
247 - let engine = PluginEngine::with_limits(limits);
248 - let ast = engine
249 - .compile("fn infinite() { let x = 0; loop { x += 1; } }")
250 - .unwrap();
251 -
252 - let result: Result<i64> = engine.call_fn(&ast, "test", "infinite");
253 - assert!(result.is_err());
254 -
255 - if let Err(PluginError::SafetyLimitExceeded { .. }) = result {
256 - // Expected
257 - } else {
258 - panic!("Expected SafetyLimitExceeded error");
259 - }
260 - }
261 -
262 - // ============ Plugin Lifecycle: Error and Recovery ============
263 -
264 - #[test]
265 - fn script_error_returns_plugin_error_not_panic() {
266 - let engine = PluginEngine::new();
267 - let ast = engine
268 - .compile(
269 - r#"
270 - fn hook_on_task_created(task) {
271 - throw "something went wrong in hook";
272 - }
273 - "#,
274 - )
275 - .unwrap();
276 -
277 - let result: Result<Dynamic> =
278 - engine.call_fn_1(&ast, "my-hook-plugin", "hook_on_task_created", "task-1");
279 - assert!(result.is_err());
280 -
281 - match result.unwrap_err() {
282 - PluginError::ScriptError { plugin, message } => {
283 - assert_eq!(plugin, "my-hook-plugin");
284 - assert!(
285 - message.contains("something went wrong in hook"),
286 - "Unexpected message: {}",
287 - message
288 - );
289 - }
290 - other => panic!("Expected ScriptError, got {:?}", other),
291 - }
292 - }
293 -
294 - #[test]
295 - fn plugin_recoverable_after_hook_error() {
296 - let engine = PluginEngine::new();
297 - let ast = engine
298 - .compile(
299 - r#"
300 - fn on_task_created(task_id) {
301 - if task_id == "bad" {
302 - throw "invalid task";
303 - }
304 - 42
305 - }
306 - "#,
307 - )
308 - .unwrap();
309 -
310 - // First call errors
311 - let err_result: Result<Dynamic> =
312 - engine.call_fn_1(&ast, "recoverable", "on_task_created", "bad".to_string());
313 - assert!(err_result.is_err());
314 - match err_result.unwrap_err() {
315 - PluginError::ScriptError { .. } => {}
316 - other => panic!("Expected ScriptError, got {:?}", other),
317 - }
318 -
319 - // Second call with valid input succeeds -- engine did not poison itself
320 - let ok_result: i64 =
321 - engine.call_fn_1(&ast, "recoverable", "on_task_created", "good".to_string()).unwrap();
322 - assert_eq!(ok_result, 42);
323 - }
324 -
325 - #[test]
326 - fn operation_limit_returns_safety_error_not_panic() {
327 - let limits = SafetyLimits {
328 - max_operations: 50,
329 - ..Default::default()
330 - };
331 - let engine = PluginEngine::with_limits(limits);
332 - let ast = engine
333 - .compile(
334 - r#"
335 - fn expensive() {
336 - let x = 0;
337 - while x < 999999 { x += 1; }
338 - x
339 - }
340 - "#,
341 - )
342 - .unwrap();
343 -
344 - let result: Result<i64> = engine.call_fn(&ast, "expensive-plugin", "expensive");
345 - assert!(result.is_err());
346 -
347 - match result.unwrap_err() {
348 - PluginError::SafetyLimitExceeded { plugin, message } => {
349 - assert_eq!(plugin, "expensive-plugin");
350 - assert!(
351 - message.contains("operations"),
352 - "Unexpected message: {}",
353 - message
354 - );
355 - }
356 - other => panic!("Expected SafetyLimitExceeded, got {:?}", other),
357 - }
358 - }
359 -
360 - #[test]
361 - fn recoverable_after_operation_limit() {
362 - let limits = SafetyLimits {
363 - max_operations: 50,
364 - ..Default::default()
365 - };
366 - let engine = PluginEngine::with_limits(limits);
367 - let ast = engine
368 - .compile(
369 - r#"
370 - fn expensive() {
371 - let x = 0;
372 - while x < 999999 { x += 1; }
373 - x
374 - }
375 - fn cheap() { 1 }
376 - "#,
377 - )
378 - .unwrap();
379 -
380 - // expensive() blows the ops limit
381 - let err_result: Result<i64> = engine.call_fn(&ast, "ops-test", "expensive");
382 - assert!(matches!(
383 - err_result,
384 - Err(PluginError::SafetyLimitExceeded { .. })
385 - ));
386 -
387 - // cheap() should still work -- the engine resets its operation counter per call
388 - let ok_result: i64 = engine.call_fn(&ast, "ops-test", "cheap").unwrap();
389 - assert_eq!(ok_result, 1);
390 - }
391 -
392 - #[test]
393 - fn compile_execute_multiple_functions_lifecycle() {
394 - let engine = PluginEngine::new();
395 -
396 - // Compile a plugin-like script with describe + parse
397 - let ast = engine
398 - .compile(
399 - r#"
400 - fn describe() {
401 - #{
402 - name: "lifecycle-test",
403 - file_extensions: ["csv"]
404 - }
405 - }
406 -
407 - fn parse(file_path, options) {
408 - let items = [];
409 - items.push(#{ description: "parsed from " + file_path });
410 - goingson::task_result(items)
411 - }
412 - "#,
413 - )
414 - .unwrap();
415 -
416 - // Validate function signatures exist
417 - assert!(engine.has_function(&ast, "describe", 0));
418 - assert!(engine.has_function(&ast, "parse", 2));
419 - assert!(!engine.has_function(&ast, "execute", 1));
420 -
421 - // Execute describe()
422 - let desc: Dynamic = engine.call_fn(&ast, "lifecycle", "describe").unwrap();
423 - let map = desc.try_cast::<rhai::Map>().unwrap();
424 - assert_eq!(
425 - map.get("name").unwrap().clone().into_string().unwrap(),
426 - "lifecycle-test"
427 - );
428 -
429 - // Execute parse()
430 - let options = rhai::Map::new();
431 - let result: Dynamic = engine
432 - .call_fn_2(&ast, "lifecycle", "parse", "/tmp/test.csv".to_string(), options)
433 - .unwrap();
434 - let result_map = result.try_cast::<rhai::Map>().unwrap();
435 - assert_eq!(
436 - result_map
437 - .get("entity_type")
438 - .unwrap()
439 - .clone()
440 - .into_string()
441 - .unwrap(),
442 - "task"
443 - );
444 - }
445 -
446 - #[test]
447 - fn eval_is_disabled() {
448 - let engine = PluginEngine::new();
449 - let result = engine.compile(r#"fn sneaky() { eval("1 + 1") }"#);
450 - // eval is disabled at the symbol level, so compilation should fail
451 - assert!(result.is_err());
452 - }
453 -
454 - #[test]
455 - fn call_fn_with_runtime_error_returns_script_error() {
456 - let engine = PluginEngine::new();
457 - let ast = engine
458 - .compile("fn divide(a, b) { a / b }")
459 - .unwrap();
460 -
461 - // Division by zero should produce a ScriptError
462 - let result: Result<Dynamic> = engine.call_fn_2(
463 - &ast,
464 - "type-test",
465 - "divide",
466 - 42_i64,
467 - 0_i64,
468 - );
469 - assert!(result.is_err());
470 - match result.unwrap_err() {
471 - PluginError::ScriptError { plugin, .. } => {
472 - assert_eq!(plugin, "type-test");
473 - }
474 - other => panic!("Expected ScriptError, got {:?}", other),
475 - }
476 - }
477 - }
@@ -1,113 +0,0 @@
1 - //! Plugin system error types.
2 -
3 - use thiserror::Error;
4 -
5 - /// Plugin system errors.
6 - #[derive(Debug, Error)]
7 - pub enum PluginError {
8 - /// Plugin manifest is invalid or missing required fields.
9 - #[error("Invalid manifest: {0}")]
10 - InvalidManifest(String),
11 -
12 - /// Plugin script has syntax errors.
13 - #[error("Script error in {plugin}: {message}")]
14 - ScriptError { plugin: String, message: String },
15 -
16 - /// Plugin execution hit safety limits.
17 - #[error("Safety limit exceeded in {plugin}: {message}")]
18 - SafetyLimitExceeded { plugin: String, message: String },
19 -
20 - /// Plugin is missing required function.
21 - #[error("Plugin {plugin} missing required function: {function}")]
22 - MissingFunction { plugin: String, function: String },
23 -
24 - /// Plugin returned invalid data.
25 - #[error("Invalid return value from {plugin}: {message}")]
26 - InvalidReturn { plugin: String, message: String },
27 -
28 - /// File I/O error.
29 - #[error("File error: {0}")]
30 - FileError(String),
31 -
32 - /// Plugin directory not found.
33 - #[error("Plugin directory not found: {0}")]
34 - DirectoryNotFound(String),
35 -
36 - /// Plugin not found by ID.
37 - #[error("Plugin not found: {0}")]
38 - PluginNotFound(String),
39 -
40 - /// Capability not granted.
41 - #[error("Permission denied: {plugin} lacks capability {capability}")]
42 - PermissionDenied { plugin: String, capability: String },
43 -
44 - /// Plugin reload requested capabilities beyond what was previously approved.
45 - #[error("Capability escalation in {plugin}: new capabilities requested: {details}")]
46 - CapabilityEscalation { plugin: String, details: String },
47 -
48 - /// Import parse error.
49 - #[error("Import parse error: {0}")]
50 - ImportError(String),
51 -
52 - /// Core error wrapper.
53 - #[error("Core error: {0}")]
54 - Core(#[from] goingson_core::CoreError),
55 - }
56 -
57 - impl PluginError {
58 - /// Creates a script error for a specific plugin.
59 - #[tracing::instrument(skip_all)]
60 - pub fn script(plugin: impl Into<String>, message: impl Into<String>) -> Self {
61 - PluginError::ScriptError {
62 - plugin: plugin.into(),
63 - message: message.into(),
64 - }
65 - }
66 -
67 - /// Creates a safety limit error.
68 - #[tracing::instrument(skip_all)]
69 - pub fn safety_limit(plugin: impl Into<String>, message: impl Into<String>) -> Self {
70 - PluginError::SafetyLimitExceeded {
71 - plugin: plugin.into(),
72 - message: message.into(),
73 - }
74 - }
75 -
76 - /// Creates a missing function error.
77 - #[tracing::instrument(skip_all)]
78 - pub fn missing_function(plugin: impl Into<String>, function: impl Into<String>) -> Self {
79 - PluginError::MissingFunction {
80 - plugin: plugin.into(),
81 - function: function.into(),
82 - }
83 - }
84 -
85 - /// Creates an invalid return value error.
86 - #[tracing::instrument(skip_all)]
87 - pub fn invalid_return(plugin: impl Into<String>, message: impl Into<String>) -> Self {
88 - PluginError::InvalidReturn {
89 - plugin: plugin.into(),
90 - message: message.into(),
91 - }
92 - }
93 -
94 - /// Creates a permission denied error.
95 - #[tracing::instrument(skip_all)]
96 - pub fn permission_denied(plugin: impl Into<String>, capability: impl Into<String>) -> Self {
97 - PluginError::PermissionDenied {
98 - plugin: plugin.into(),
99 - capability: capability.into(),
100 - }
101 - }
102 -
103 - /// Creates a capability escalation error.
104 - pub fn capability_escalation(plugin: impl Into<String>, details: impl Into<String>) -> Self {
105 - PluginError::CapabilityEscalation {
106 - plugin: plugin.into(),
107 - details: details.into(),
108 - }
109 - }
110 - }
111 -
112 - /// Result type for plugin operations.
113 - pub type Result<T> = std::result::Result<T, PluginError>;
@@ -1,50 +0,0 @@
1 - //! Rhai Plugin Runtime for GoingsOn.
2 - //!
3 - //! This crate provides a sandboxed plugin execution environment using the Rhai
4 - //! scripting language. Plugins can extend GoingsOn with:
5 - //!
6 - //! - **Import Adapters**: Transform external data formats (CSV, JSON, etc.) into GoingsOn entities
7 - //! - **Export Adapters**: Export GoingsOn data to external formats
8 - //! - **Custom Commands**: Add new functionality to the app
9 - //! - **Lifecycle Hooks**: React to app events (on_task_created, etc.)
10 - //!
11 - //! # Safety
12 - //!
13 - //! Plugins run in a sandboxed Rhai engine with configurable limits:
14 - //! - Maximum operations (computation time)
15 - //! - Maximum call stack depth (recursion)
16 - //! - Maximum string/array sizes (memory)
17 - //! - Disabled dangerous operations (eval, system access)
18 - //!
19 - //! # Example
20 - //!
21 - //! ```rust,ignore
22 - //! use goingson_plugin_runtime::{PluginLoader, PluginEngine};
23 - //!
24 - //! let loader = PluginLoader::new("~/.config/goingson/plugins")?;
25 - //! let plugins = loader.discover_plugins()?;
26 - //!
27 - //! for plugin in plugins {
28 - //! println!("Found plugin: {} v{}", plugin.name, plugin.version);
29 - //! }
30 - //! ```
31 -
32 - mod api;
33 - mod engine;
34 - mod error;
35 - mod loader;
36 - mod manifest;
37 - mod registry;
38 -
39 - pub use api::PluginApi;
40 - pub use engine::{PluginEngine, SafetyLimits};
41 - pub use error::{PluginError, Result};
42 - pub use loader::PluginLoader;
43 - pub use manifest::PluginManifest;
44 - pub use registry::PluginRegistry;
45 -
46 - // Re-export core types for convenience
47 - pub use goingson_core::{
48 - ImportEntityType, ImportExecuteResult, ImportItem, ImportItemData, ImportOptions,
49 - ImportParseResult, PluginCapabilities, PluginMeta, PluginType,
50 - };
@@ -1,1025 +0,0 @@
1 - //! Plugin discovery and loading.
2 - //!
3 - //! Scans plugin directories, loads manifests, and compiles scripts.
4 -
5 - use std::collections::HashMap;
6 - use std::path::{Path, PathBuf};
7 - use std::sync::Arc;
8 -
9 - use rhai::AST;
10 -
11 - use crate::engine::PluginEngine;
12 - use crate::error::{PluginError, Result};
13 - use crate::manifest::PluginManifest;
14 - use goingson_core::PluginMeta;
15 -
16 - /// A loaded plugin with its compiled script.
17 - #[derive(Debug, Clone)]
18 - pub struct LoadedPlugin {
19 - /// Plugin metadata from manifest.
20 - pub meta: PluginMeta,
21 - /// Path to the plugin directory.
22 - pub path: PathBuf,
23 - /// Compiled Rhai AST.
24 - pub ast: Arc<AST>,
25 - }
26 -
27 - /// Plugin loader that discovers and loads plugins from the filesystem.
28 - pub struct PluginLoader {
29 - /// Base directory for plugins (e.g., ~/.config/goingson/plugins).
30 - plugins_dir: PathBuf,
31 - /// Shared engine for compilation.
32 - engine: Arc<PluginEngine>,
33 - /// Cache of loaded plugins.
34 - loaded: HashMap<String, LoadedPlugin>,
35 - }
36 -
37 - impl PluginLoader {
38 - /// Creates a new plugin loader for the given directory.
39 - #[tracing::instrument(skip_all)]
40 - pub fn new(plugins_dir: impl Into<PathBuf>) -> Result<Self> {
41 - let plugins_dir = plugins_dir.into();
42 -
43 - // Create directories if they don't exist
44 - let enabled_dir = plugins_dir.join("enabled");
45 - let available_dir = plugins_dir.join("available");
46 -
47 - std::fs::create_dir_all(&enabled_dir)
48 - .map_err(|e| PluginError::FileError(format!("Failed to create enabled dir: {}", e)))?;
49 - std::fs::create_dir_all(&available_dir)
50 - .map_err(|e| PluginError::FileError(format!("Failed to create available dir: {}", e)))?;
51 -
52 - Ok(Self {
53 - plugins_dir,
54 - engine: Arc::new(PluginEngine::new()),
55 - loaded: HashMap::new(),
56 - })
57 - }
58 -
59 - /// Creates a plugin loader with a custom engine.
60 - #[tracing::instrument(skip_all)]
61 - pub fn with_engine(plugins_dir: impl Into<PathBuf>, engine: Arc<PluginEngine>) -> Result<Self> {
62 - let plugins_dir = plugins_dir.into();
63 -
64 - let enabled_dir = plugins_dir.join("enabled");
65 - let available_dir = plugins_dir.join("available");
66 -
67 - std::fs::create_dir_all(&enabled_dir)
68 - .map_err(|e| PluginError::FileError(format!("Failed to create enabled dir: {}", e)))?;
69 - std::fs::create_dir_all(&available_dir)
70 - .map_err(|e| PluginError::FileError(format!("Failed to create available dir: {}", e)))?;
71 -
72 - Ok(Self {
73 - plugins_dir,
74 - engine,
75 - loaded: HashMap::new(),
76 - })
77 - }
78 -
79 - /// Returns the base plugins directory.
80 - #[tracing::instrument(skip_all)]
81 - pub fn plugins_dir(&self) -> &Path {
82 - &self.plugins_dir
83 - }
84 -
85 - /// Returns the enabled plugins directory.
86 - #[tracing::instrument(skip_all)]
87 - pub fn enabled_dir(&self) -> PathBuf {
88 - self.plugins_dir.join("enabled")
89 - }
90 -
91 - /// Returns the available plugins directory.
92 - #[tracing::instrument(skip_all)]
93 - pub fn available_dir(&self) -> PathBuf {
94 - self.plugins_dir.join("available")
95 - }
96 -
97 - /// Discovers all enabled plugins (symlinks in enabled/).
98 - #[tracing::instrument(skip_all)]
99 - pub fn discover_enabled(&mut self) -> Result<Vec<PluginMeta>> {
100 - let enabled_dir = self.enabled_dir();
101 - let mut plugins = Vec::new();
102 -
103 - if !enabled_dir.exists() {
104 - return Ok(plugins);
105 - }
106 -
107 - for entry in std::fs::read_dir(&enabled_dir)
108 - .map_err(|e| PluginError::FileError(format!("Failed to read enabled dir: {}", e)))?
109 - {
110 - let entry = entry.map_err(|e| PluginError::FileError(e.to_string()))?;
111 - let path = entry.path();
112 -
113 - // Follow symlinks to get the actual plugin directory
114 - let plugin_path = if path.is_symlink() {
115 - let target = std::fs::read_link(&path)
116 - .map_err(|e| PluginError::FileError(format!("Failed to read symlink: {}", e)))?;
117 - // Resolve the symlink target to its canonical path.
118 - // If the target doesn't exist (dangling symlink), skip it entirely
119 - // to prevent sandbox escape via delayed target creation.
120 - let canonical = match std::fs::canonicalize(&target) {
121 - Ok(p) => p,
122 - Err(_) => {
123 - tracing::warn!(
124 - "Skipping symlink '{}': target '{}' does not exist",
125 - path.display(), target.display()
126 - );
127 - continue;
128 - }
129 - };
130 - let available_canonical = std::fs::canonicalize(self.available_dir())
131 - .unwrap_or_else(|_| self.available_dir());
132 - if !canonical.starts_with(&available_canonical) {
133 - tracing::warn!(
134 - "Skipping symlink '{}': target '{}' is outside available/",
135 - path.display(), canonical.display()
136 - );
137 - continue;
138 - }
139 - canonical
140 - } else if path.is_dir() {
141 - path.clone()
142 - } else {
143 - continue;
144 - };
145 -
146 - // The plugin ID is the directory name
147 - let plugin_id = path
148 - .file_name()
149 - .and_then(|n| n.to_str())
150 - .ok_or_else(|| PluginError::FileError("Invalid plugin path".to_string()))?
151 - .to_string();
152 -
153 - match self.load_plugin(&plugin_id, &plugin_path) {
154 - Ok(loaded) => plugins.push(loaded.meta.clone()),
155 - Err(e) => {
156 - tracing::warn!("Failed to load plugin '{}': {}", plugin_id, e);
157 - }
158 - }
159 - }
160 -
161 - Ok(plugins)
162 - }
163 -
164 - /// Discovers all available plugins (directories in available/).
165 - #[tracing::instrument(skip_all)]
166 - pub fn discover_available(&self) -> Result<Vec<PluginMeta>> {
167 - let available_dir = self.available_dir();
168 - let mut plugins = Vec::new();
169 -
170 - if !available_dir.exists() {
171 - return Ok(plugins);
172 - }
173 -
174 - for entry in std::fs::read_dir(&available_dir)
175 - .map_err(|e| PluginError::FileError(format!("Failed to read available dir: {}", e)))?
176 - {
177 - let entry = entry.map_err(|e| PluginError::FileError(e.to_string()))?;
178 - let path = entry.path();
179 -
180 - if !path.is_dir() {
181 - continue;
182 - }
183 -
184 - let plugin_id = path
185 - .file_name()
186 - .and_then(|n| n.to_str())
187 - .ok_or_else(|| PluginError::FileError("Invalid plugin path".to_string()))?
188 - .to_string();
189 -
190 - let manifest_path = path.join("plugin.toml");
191 - if !manifest_path.exists() {
192 - tracing::warn!("Plugin '{}' missing plugin.toml", plugin_id);
193 - continue;
194 - }
195 -
196 - match PluginManifest::from_file(&manifest_path) {
197 - Ok(manifest) => match manifest.to_meta(plugin_id.clone()) {
198 - Ok(meta) => plugins.push(meta),
199 - Err(e) => tracing::warn!("Invalid manifest for '{}': {}", plugin_id, e),
200 - },
201 - Err(e) => tracing::warn!("Failed to parse manifest for '{}': {}", plugin_id, e),
202 - }
203 - }
204 -
205 - Ok(plugins)
206 - }
207 -
208 - /// Loads a plugin from a directory.
209 - #[tracing::instrument(skip_all)]
210 - pub fn load_plugin(&mut self, plugin_id: &str, plugin_path: &Path) -> Result<LoadedPlugin> {
211 - // Check cache first
212 - if let Some(loaded) = self.loaded.get(plugin_id) {
213 - return Ok(loaded.clone());
214 - }
215 -
216 - // Load manifest
217 - let manifest_path = plugin_path.join("plugin.toml");
218 - if !manifest_path.exists() {
219 - return Err(PluginError::FileError(format!(
220 - "Plugin '{}' missing plugin.toml",
221 - plugin_id
222 - )));
223 - }
224 -
225 - let manifest = PluginManifest::from_file(&manifest_path)?;
226 - let meta = manifest.to_meta(plugin_id.to_string())?;
227 -
228 - // Load and compile script
229 - let script_path = plugin_path.join("main.rhai");
230 - if !script_path.exists() {
231 - return Err(PluginError::FileError(format!(
232 - "Plugin '{}' missing main.rhai",
233 - plugin_id
234 - )));
235 - }
236 -
237 - let script = std::fs::read_to_string(&script_path)
238 - .map_err(|e| PluginError::FileError(format!("Failed to read script: {}", e)))?;
239 -
240 - let ast = self.engine.compile_plugin(plugin_id, &script)?;
241 -
242 - // Validate required functions based on plugin type
243 - self.validate_plugin_functions(plugin_id, &meta, &ast)?;
244 -
245 - let loaded = LoadedPlugin {
246 - meta,
247 - path: plugin_path.to_path_buf(),
248 - ast: Arc::new(ast),
249 - };
250 -
251 - self.loaded.insert(plugin_id.to_string(), loaded.clone());
252 -
253 - Ok(loaded)
254 - }
255 -
256 - /// Validates that a plugin has the required functions for its type.
257 - fn validate_plugin_functions(
258 - &self,
259 - plugin_id: &str,
260 - meta: &PluginMeta,
261 - ast: &AST,
262 - ) -> Result<()> {
263 - match &meta.plugin_type {
264 - goingson_core::PluginType::Import(_) => {
265 - // Import plugins must have describe() and parse(file_path, options)
266 - if !self.engine.has_function(ast, "describe", 0) {
267 - return Err(PluginError::missing_function(plugin_id, "describe"));
268 - }
269 - if !self.engine.has_function(ast, "parse", 2) {
270 - return Err(PluginError::missing_function(plugin_id, "parse"));
271 - }
272 - }
273 - goingson_core::PluginType::Export(_) => {
274 - // Export plugins must have describe() and export(data, options)
275 - if !self.engine.has_function(ast, "describe", 0) {
276 - return Err(PluginError::missing_function(plugin_id, "describe"));
277 - }
278 - if !self.engine.has_function(ast, "export", 2) {
279 - return Err(PluginError::missing_function(plugin_id, "export"));
280 - }
281 - }
282 - goingson_core::PluginType::Command => {
283 - // Command plugins must have describe() and execute(args)
284 - if !self.engine.has_function(ast, "describe", 0) {
285 - return Err(PluginError::missing_function(plugin_id, "describe"));
286 - }
287 - if !self.engine.has_function(ast, "execute", 1) {
288 - return Err(PluginError::missing_function(plugin_id, "execute"));
289 - }
290 - }
291 - goingson_core::PluginType::Hook => {
292 - // Hook plugins must have describe() and at least one hook handler
293 - if !self.engine.has_function(ast, "describe", 0) {
294 - return Err(PluginError::missing_function(plugin_id, "describe"));
295 - }
296 - }
297 - }
298 -
299 - Ok(())
300 - }
301 -
302 - /// Reloads a plugin from disk.
303 - ///
304 - /// Removes the cached `LoadedPlugin` first so `load_plugin` doesn't
305 - /// short-circuit to the stale AST, then re-reads and recompiles from
306 - /// the same directory path. Rejects the reload if capabilities escalated.
307 - #[tracing::instrument(skip_all)]
308 - pub fn reload_plugin(&mut self, plugin_id: &str) -> Result<LoadedPlugin> {
309 - let old = self
310 - .loaded
311 - .get(plugin_id)
312 - .ok_or_else(|| PluginError::PluginNotFound(plugin_id.to_string()))?;
313 -
314 - let old_caps = old.meta.capabilities.clone();
315 - let path = old.path.clone();
316 -
317 - self.loaded.remove(plugin_id);
318 -
319 - let new_plugin = self.load_plugin(plugin_id, &path)?;
320 -
321 - // Detect escalation: any capability that was false and is now true
322 - let new_caps = &new_plugin.meta.capabilities;
323 - let mut escalated = Vec::new();
324 - if !old_caps.file_read && new_caps.file_read { escalated.push("file_read"); }
325 - if !old_caps.database_write && new_caps.database_write { escalated.push("database_write"); }
326 - if !old_caps.network && new_caps.network { escalated.push("network"); }
327 -
328 - if !escalated.is_empty() {
329 - self.loaded.remove(plugin_id);
330 - return Err(PluginError::capability_escalation(
331 - plugin_id,
332 - escalated.join(", "),
333 - ));
334 - }
335 -
336 - Ok(new_plugin)
337 - }
338 -
339 - /// Gets a loaded plugin by ID.
340 - #[tracing::instrument(skip_all)]
341 - pub fn get_plugin(&self, plugin_id: &str) -> Option<&LoadedPlugin> {
342 - self.loaded.get(plugin_id)
343 - }
344 -
345 - /// Returns the shared engine.
346 - #[tracing::instrument(skip_all)]
347 - pub fn engine(&self) -> &Arc<PluginEngine> {
348 - &self.engine
349 - }
350 -
351 - /// Enables a plugin by creating a symlink from `enabled/` → `available/`.
352 - ///
353 - /// Uses the `available/` + `enabled/` directory pattern (similar to
354 - /// nginx `sites-available`/`sites-enabled`): plugin code lives in
355 - /// `available/<id>/`, and enabling creates a symlink in `enabled/<id>`
356 - /// pointing to it. This keeps a clean separation between "installed"
357 - /// and "active" plugins without copying files.
358 - #[tracing::instrument(skip_all)]
359 - pub fn enable_plugin(&self, plugin_id: &str) -> Result<()> {
360 - let available_path = self.available_dir().join(plugin_id);
361 - let enabled_path = self.enabled_dir().join(plugin_id);
362 -
363 - if !available_path.exists() {
364 - return Err(PluginError::PluginNotFound(plugin_id.to_string()));
365 - }
366 -
367 - if enabled_path.exists() {
368 - return Ok(());
369 - }
370 -
371 - // Unix uses fs::symlink; Windows uses symlink_dir (directory junctions).
372 - #[cfg(unix)]
373 - {
374 - std::os::unix::fs::symlink(&available_path, &enabled_path).map_err(|e| {
375 - PluginError::FileError(format!("Failed to create symlink: {}", e))
376 - })?;
377 - }
378 -
379 - #[cfg(windows)]
380 - {
381 - std::os::windows::fs::symlink_dir(&available_path, &enabled_path).map_err(|e| {
382 - PluginError::FileError(format!("Failed to create symlink: {}", e))
383 - })?;
384 - }
385 -
386 - Ok(())
387 - }
388 -
389 - /// Disables a plugin by removing its symlink from enabled/.
390 - #[tracing::instrument(skip_all)]
391 - pub fn disable_plugin(&mut self, plugin_id: &str) -> Result<()> {
392 - let enabled_path = self.enabled_dir().join(plugin_id);
393 -
394 - if enabled_path.exists() || enabled_path.is_symlink() {
395 - std::fs::remove_file(&enabled_path)
396 - .map_err(|e| PluginError::FileError(format!("Failed to remove symlink: {}", e)))?;
397 - }
398 -
399 - // Remove from cache
400 - self.loaded.remove(plugin_id);
401 -
402 - Ok(())
403 - }
404 -
405 - /// Checks if a plugin is enabled.
406 - #[tracing::instrument(skip_all)]
407 - pub fn is_enabled(&self, plugin_id: &str) -> bool {
408 - let enabled_path = self.enabled_dir().join(plugin_id);
409 - enabled_path.exists() || enabled_path.is_symlink()
410 - }
411 -
412 - /// Returns a reference to the loaded plugins map.
413 - #[tracing::instrument(skip_all)]
414 - pub fn loaded(&self) -> &HashMap<String, LoadedPlugin> {
415 - &self.loaded
416 - }
417 - }
418 -
419 - #[cfg(test)]
420 - mod tests {
421 - use super::*;
422 - use crate::engine::SafetyLimits;
423 - use tempfile::TempDir;
424 -
425 - fn create_test_plugin(dir: &Path, name: &str) {
426 - let plugin_dir = dir.join("available").join(name);
427 - std::fs::create_dir_all(&plugin_dir).unwrap();
428 -
429 - let manifest = format!(
430 - r#"
431 - [plugin]
432 - name = "{}"
433 - version = "1.0.0"
434 - description = "Test plugin"
435 -
436 - [plugin.type]
437 - kind = "import"
438 -
439 - [plugin.import]
440 - file_extensions = ["csv"]
441 - entity_types = ["task"]
442 -
443 - [plugin.capabilities]
444 - file_read = true
445 - "#,
446 - name
447 - );
448 -
449 - std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
450 -
451 - let script = r#"
452 - fn describe() {
453 - #{
454 - name: "Test",
455 - file_extensions: ["csv"]
456 - }
457 - }
458 -
459 - fn parse(file_path, options) {
460 - goingson::task_result([])
461 - }
462 - "#;
463 - std::fs::write(plugin_dir.join("main.rhai"), script).unwrap();
464 - }
465 -
466 - #[test]
467 - fn test_discover_available() {
468 - let temp_dir = TempDir::new().unwrap();
469 - create_test_plugin(temp_dir.path(), "test-plugin");
470 -
471 - let loader = PluginLoader::new(temp_dir.path()).unwrap();
472 - let plugins = loader.discover_available().unwrap();
473 -
474 - assert_eq!(plugins.len(), 1);
475 - assert_eq!(plugins[0].id, "test-plugin");
476 - }
477 -
478 - #[test]
479 - fn test_enable_disable_plugin() {
480 - let temp_dir = TempDir::new().unwrap();
481 - create_test_plugin(temp_dir.path(), "test-plugin");
482 -
483 - let mut loader = PluginLoader::new(temp_dir.path()).unwrap();
484 -
485 - // Initially not enabled
486 - assert!(!loader.is_enabled("test-plugin"));
487 -
488 - // Enable
489 - loader.enable_plugin("test-plugin").unwrap();
490 - assert!(loader.is_enabled("test-plugin"));
491 -
492 - // Discover enabled
493 - let enabled = loader.discover_enabled().unwrap();
494 - assert_eq!(enabled.len(), 1);
495 -
496 - // Disable
497 - loader.disable_plugin("test-plugin").unwrap();
498 - assert!(!loader.is_enabled("test-plugin"));
499 - }
500 -
Lines truncated
@@ -1,343 +0,0 @@
1 - //! Plugin manifest parsing.
2 - //!
3 - //! Parses plugin.toml manifests into structured types.
4 -
5 - use serde::Deserialize;
6 - use std::path::Path;
7 -
8 - use crate::error::{PluginError, Result};
9 - use goingson_core::{
10 - ExportPluginConfig, ImportPluginConfig, PluginCapabilities, PluginMeta, PluginType,
11 - };
12 -
13 - /// Raw manifest as parsed from TOML.
14 - #[derive(Debug, Deserialize)]
15 - pub struct PluginManifest {
16 - pub plugin: PluginSection,
17 - }
18 -
19 - #[derive(Debug, Deserialize)]
20 - pub struct PluginSection {
21 - pub name: String,
22 - pub version: String,
23 - pub description: String,
24 - #[serde(default)]
25 - pub author: String,
26 - pub min_app_version: Option<String>,
27 - #[serde(rename = "type")]
28 - pub plugin_type: PluginTypeSection,
29 - #[serde(default)]
30 - pub import: Option<ImportSection>,
31 - #[serde(default)]
32 - pub export: Option<ExportSection>,
33 - #[serde(default)]
34 - pub capabilities: CapabilitiesSection,
35 - }
36 -
37 - #[derive(Debug, Deserialize)]
38 - pub struct PluginTypeSection {
39 - pub kind: String,
40 - }
41 -
42 - #[derive(Debug, Deserialize, Default)]
43 - pub struct ImportSection {
44 - #[serde(default)]
45 - pub file_extensions: Vec<String>,
46 - #[serde(default)]
47 - pub entity_types: Vec<String>,
48 - }
49 -
50 - #[derive(Debug, Deserialize, Default)]
51 - pub struct ExportSection {
52 - #[serde(default)]
53 - pub file_extension: String,
54 - #[serde(default)]
55 - pub entity_types: Vec<String>,
56 - }
57 -
58 - #[derive(Debug, Deserialize, Default)]
59 - pub struct CapabilitiesSection {
60 - #[serde(default)]
61 - pub file_read: bool,
62 - #[serde(default)]
63 - pub database_write: bool,
64 - #[serde(default)]
65 - pub network: bool,
66 - }
67 -
68 - impl PluginManifest {
69 - /// Parses a plugin manifest from a TOML file.
70 - #[tracing::instrument(skip_all)]
71 - pub fn from_file(path: &Path) -> Result<Self> {
72 - let content = std::fs::read_to_string(path).map_err(|e| {
73 - PluginError::FileError(format!("Failed to read {}: {}", path.display(), e))
74 - })?;
75 - Self::parse(&content)
76 - }
77 -
78 - /// Parses a plugin manifest from a TOML string.
79 - #[tracing::instrument(skip_all)]
80 - pub fn parse(content: &str) -> Result<Self> {
81 - toml::from_str(content)
82 - .map_err(|e| PluginError::InvalidManifest(format!("TOML parse error: {}", e)))
83 - }
84 -
85 - /// Converts the raw manifest to a PluginMeta with the given ID.
86 - #[tracing::instrument(skip_all)]
87 - pub fn to_meta(&self, id: String) -> Result<PluginMeta> {
88 - let plugin_type = self.parse_plugin_type()?;
89 -
90 - Ok(PluginMeta {
91 - id,
92 - name: self.plugin.name.clone(),
93 - version: self.plugin.version.clone(),
94 - description: self.plugin.description.clone(),
95 - author: self.plugin.author.clone(),
96 - min_app_version: self.plugin.min_app_version.clone(),
97 - plugin_type,
98 - capabilities: PluginCapabilities {
99 - file_read: self.plugin.capabilities.file_read,
100 - database_write: self.plugin.capabilities.database_write,
101 - network: self.plugin.capabilities.network,
102 - },
103 - })
104 - }
105 -
106 - fn parse_plugin_type(&self) -> Result<PluginType> {
107 - match self.plugin.plugin_type.kind.as_str() {
108 - "import" => {
109 - let import = self.plugin.import.as_ref().ok_or_else(|| {
110 - PluginError::InvalidManifest(
111 - "Import plugin requires [plugin.import] section".to_string(),
112 - )
113 - })?;
114 - Ok(PluginType::Import(ImportPluginConfig {
115 - file_extensions: import.file_extensions.clone(),
116 - entity_types: import.entity_types.clone(),
117 - }))
118 - }
119 - "export" => {
120 - let export = self.plugin.export.as_ref().ok_or_else(|| {
121 - PluginError::InvalidManifest(
122 - "Export plugin requires [plugin.export] section".to_string(),
123 - )
124 - })?;
125 - Ok(PluginType::Export(ExportPluginConfig {
126 - file_extension: export.file_extension.clone(),
127 - entity_types: export.entity_types.clone(),
128 - }))
129 - }
130 - "command" => Ok(PluginType::Command),
131 - "hook" => Ok(PluginType::Hook),
132 - other => Err(PluginError::InvalidManifest(format!(
133 - "Unknown plugin kind: {}",
134 - other
135 - ))),
136 - }
137 - }
138 - }
139 -
140 - #[cfg(test)]
141 - mod tests {
142 - use super::*;
143 -
144 - #[test]
145 - fn test_parse_import_manifest() {
146 - let toml = r#"
147 - [plugin]
148 - name = "csv-import"
149 - version = "1.0.0"
150 - description = "Import tasks from CSV files"
151 - author = "Community"
152 - min_app_version = "0.2.0"
153 -
154 - [plugin.type]
155 - kind = "import"
156 -
157 - [plugin.import]
158 - file_extensions = ["csv", "tsv"]
159 - entity_types = ["task", "project"]
160 -
161 - [plugin.capabilities]
162 - file_read = true
163 - database_write = true
164 - network = false
165 - "#;
166 -
167 - let manifest = PluginManifest::parse(toml).unwrap();
168 - let meta = manifest.to_meta("csv-import".to_string()).unwrap();
169 -
170 - assert_eq!(meta.name, "csv-import");
171 - assert_eq!(meta.version, "1.0.0");
172 - assert!(matches!(meta.plugin_type, PluginType::Import(_)));
173 -
174 - if let PluginType::Import(config) = &meta.plugin_type {
175 - assert_eq!(config.file_extensions, vec!["csv", "tsv"]);
176 - assert_eq!(config.entity_types, vec!["task", "project"]);
177 - }
178 -
179 - assert!(meta.capabilities.file_read);
180 - assert!(meta.capabilities.database_write);
181 - assert!(!meta.capabilities.network);
182 - }
183 -
184 - #[test]
185 - fn test_parse_export_manifest() {
186 - let toml = r#"
187 - [plugin]
188 - name = "json-export"
189 - version = "1.0.0"
190 - description = "Export tasks to JSON"
191 - author = "Community"
192 -
193 - [plugin.type]
194 - kind = "export"
195 -
196 - [plugin.export]
197 - file_extension = "json"
198 - entity_types = ["task"]
199 -
200 - [plugin.capabilities]
201 - file_read = false
202 - database_write = false
203 - "#;
204 -
205 - let manifest = PluginManifest::parse(toml).unwrap();
206 - let meta = manifest.to_meta("json-export".to_string()).unwrap();
207 -
208 - assert!(matches!(meta.plugin_type, PluginType::Export(_)));
209 - }
210 -
211 - #[test]
212 - fn test_missing_import_section() {
213 - let toml = r#"
214 - [plugin]
215 - name = "bad-plugin"
216 - version = "1.0.0"
217 - description = "Missing import section"
218 -
219 - [plugin.type]
220 - kind = "import"
221 -
222 - [plugin.capabilities]
223 - "#;
224 -
225 - let manifest = PluginManifest::parse(toml).unwrap();
226 - let result = manifest.to_meta("bad-plugin".to_string());
227 - assert!(result.is_err());
228 - }
229 -
230 - // ============ Corrupt / Invalid Manifest ============
231 -
232 - #[test]
233 - fn corrupt_toml_returns_error_not_panic() {
234 - let garbage = "{{{{ not valid toml at all !@#$";
235 - let result = PluginManifest::parse(garbage);
236 - assert!(result.is_err());
237 - match result.unwrap_err() {
238 - PluginError::InvalidManifest(msg) => {
239 - assert!(msg.contains("TOML parse error"), "Unexpected message: {}", msg);
240 - }
241 - other => panic!("Expected InvalidManifest, got {:?}", other),
242 - }
243 - }
244 -
245 - #[test]
246 - fn empty_string_manifest_returns_error() {
247 - let result = PluginManifest::parse("");
248 - assert!(result.is_err());
249 - match result.unwrap_err() {
250 - PluginError::InvalidManifest(_) => {}
251 - other => panic!("Expected InvalidManifest, got {:?}", other),
252 - }
253 - }
254 -
255 - #[test]
256 - fn manifest_missing_required_fields_returns_error() {
257 - // Valid TOML but missing required plugin fields (name, version, description)
258 - let toml = r#"
259 - [plugin]
260 - name = "incomplete"
261 - "#;
262 - let result = PluginManifest::parse(toml);
263 - assert!(result.is_err());
264 - }
265 -
266 - #[test]
267 - fn manifest_with_truncated_toml_returns_error() {
268 - // Simulates a file that was partially written (e.g. crash mid-update)
269 - let truncated = r#"
270 - [plugin]
271 - name = "half-written"
272 - version = "1.0.0"
273 - description = "Truncated during write"
274 -
275 - [plugin.type]
276 - kind = "import"
277 -
278 - [plugin.import
279 - "#;
280 - let result = PluginManifest::parse(truncated);
281 - assert!(result.is_err());
282 - match result.unwrap_err() {
283 - PluginError::InvalidManifest(msg) => {
284 - assert!(msg.contains("TOML parse error"), "Unexpected message: {}", msg);
285 - }
286 - other => panic!("Expected InvalidManifest, got {:?}", other),
287 - }
288 - }
289 -
290 - // ============ Unsupported Plugin Kind ============
291 -
292 - #[test]
293 - fn unsupported_plugin_kind_returns_error() {
294 - let toml = r#"
295 - [plugin]
296 - name = "bad-kind"
297 - version = "1.0.0"
298 - description = "Uses a kind that does not exist"
299 -
300 - [plugin.type]
301 - kind = "transformer"
302 -
303 - [plugin.capabilities]
304 - "#;
305 - let manifest = PluginManifest::parse(toml).unwrap();
306 - let result = manifest.to_meta("bad-kind".to_string());
307 - assert!(result.is_err());
308 - match result.unwrap_err() {
309 - PluginError::InvalidManifest(msg) => {
310 - assert!(
311 - msg.contains("Unknown plugin kind: transformer"),
312 - "Unexpected message: {}",
313 - msg
314 - );
315 - }
316 - other => panic!("Expected InvalidManifest, got {:?}", other),
317 - }
318 - }
319 -
320 - #[test]
321 - fn empty_plugin_kind_returns_error() {
322 - let toml = r#"
323 - [plugin]
324 - name = "empty-kind"
325 - version = "1.0.0"
326 - description = "Kind field is empty"
327 -
328 - [plugin.type]
329 - kind = ""
330 -
331 - [plugin.capabilities]
332 - "#;
333 - let manifest = PluginManifest::parse(toml).unwrap();
334 - let result = manifest.to_meta("empty-kind".to_string());
335 - assert!(result.is_err());
336 - match result.unwrap_err() {
337 - PluginError::InvalidManifest(msg) => {
338 - assert!(msg.contains("Unknown plugin kind"), "Unexpected message: {}", msg);
339 - }
340 - other => panic!("Expected InvalidManifest, got {:?}", other),
341 - }
342 - }
343 - }
@@ -1,1243 +0,0 @@
1 - //! Plugin registry for managing and executing plugins.
2 - //!
3 - //! Provides high-level operations for listing, previewing, and executing imports.
4 -
5 - use std::sync::Arc;
6 -
7 - use rhai::{Dynamic, Map};
8 -
9 - use crate::api::{dynamic_to_import_result, PluginApi, PluginApiContext};
10 - use crate::engine::PluginEngine;
11 - use crate::error::{PluginError, Result};
12 - use crate::loader::{LoadedPlugin, PluginLoader};
13 - use goingson_core::{
14 - ImportOptions, ImportParseResult, PluginMeta, PluginType,
15 - };
16 -
17 - /// Registry for managing active plugins.
18 - pub struct PluginRegistry {
19 - loader: PluginLoader,
20 - }
21 -
22 - impl PluginRegistry {
23 - /// Creates a new plugin registry.
24 - #[tracing::instrument(skip_all)]
25 - pub fn new(plugins_dir: impl Into<std::path::PathBuf>) -> Result<Self> {
26 - let loader = PluginLoader::new(plugins_dir)?;
27 - Ok(Self { loader })
28 - }
29 -
30 - /// Creates a registry with a custom engine.
31 - #[tracing::instrument(skip_all)]
32 - pub fn with_engine(
33 - plugins_dir: impl Into<std::path::PathBuf>,
34 - engine: Arc<PluginEngine>,
35 - ) -> Result<Self> {
36 - let loader = PluginLoader::with_engine(plugins_dir, engine)?;
37 - Ok(Self { loader })
38 - }
39 -
40 - /// Returns the plugin loader.
41 - #[tracing::instrument(skip_all)]
42 - pub fn loader(&self) -> &PluginLoader {
43 - &self.loader
44 - }
45 -
46 - /// Returns a mutable reference to the plugin loader.
47 - #[tracing::instrument(skip_all)]
48 - pub fn loader_mut(&mut self) -> &mut PluginLoader {
49 - &mut self.loader
50 - }
51 -
52 - /// Initializes the registry by discovering enabled plugins.
53 - #[tracing::instrument(skip_all)]
54 - pub fn initialize(&mut self) -> Result<Vec<PluginMeta>> {
55 - self.loader.discover_enabled()
56 - }
57 -
58 - /// Lists all available import plugins.
59 - #[tracing::instrument(skip_all)]
60 - pub fn list_import_plugins(&self) -> Result<Vec<PluginMeta>> {
61 - let available = self.loader.discover_available()?;
62 - Ok(available
63 - .into_iter()
64 - .filter(|p| matches!(p.plugin_type, PluginType::Import(_)))
65 - .collect())
66 - }
67 -
68 - /// Lists all enabled import plugins.
69 - #[tracing::instrument(skip_all)]
70 - pub fn list_enabled_import_plugins(&self) -> Vec<PluginMeta> {
71 - self.loader
72 - .loaded()
73 - .iter()
74 - .filter(|(_, p)| matches!(p.meta.plugin_type, PluginType::Import(_)))
75 - .map(|(_, p)| p.meta.clone())
76 - .collect()
77 - }
78 -
79 - /// Gets plugins that can handle a specific file extension.
80 - #[tracing::instrument(skip_all)]
81 - pub fn get_plugins_for_extension(&self, extension: &str) -> Vec<PluginMeta> {
82 - let ext_lower = extension.to_lowercase();
83 - self.loader
84 - .loaded()
85 - .iter()
86 - .filter_map(|(_, p)| {
87 - if let PluginType::Import(config) = &p.meta.plugin_type {
88 - if config.file_extensions.iter().any(|e| e.to_lowercase() == ext_lower) {
89 - return Some(p.meta.clone());
90 - }
91 - }
92 - None
93 - })
94 - .collect()
95 - }
96 -
97 - /// Previews an import by running the plugin's parse function.
98 - #[tracing::instrument(skip_all)]
99 - pub fn preview_import(
100 - &self,
101 - plugin_id: &str,
102 - file_path: &str,
103 - options: ImportOptions,
104 - projects: Vec<(String, String)>,
105 - ) -> Result<ImportParseResult> {
106 - let plugin = self
107 - .loader
108 - .get_plugin(plugin_id)
109 - .ok_or_else(|| PluginError::PluginNotFound(plugin_id.to_string()))?;
110 -
111 - // Verify it's an import plugin
112 - if !matches!(plugin.meta.plugin_type, PluginType::Import(_)) {
113 - return Err(PluginError::InvalidManifest(format!(
114 - "'{}' is not an import plugin",
115 - plugin_id
116 - )));
117 - }
118 -
119 - // Set up context
120 - let ctx = PluginApiContext {
121 - import_file_path: Some(file_path.to_string()),
122 - can_read_files: plugin.meta.capabilities.file_read,
123 - can_write_db: plugin.meta.capabilities.database_write,
124 - logs: Vec::new(),
125 - progress: None,
126 - projects,
127 - };
128 - PluginApiContext::set(ctx);
129 -
130 - // Guard ensures context is cleared even if call_fn_2 errors
131 - struct ContextGuard;
132 - impl Drop for ContextGuard {
133 - fn drop(&mut self) {
134 - PluginApiContext::clear();
135 - }
136 - }
137 - let _guard = ContextGuard;
138 -
139 - // Convert options to Rhai map
140 - let options_map = options_to_rhai_map(&options);
141 -
142 - // Call parse function
143 - let engine = self.loader.engine();
144 - let result = engine.call_fn_2::<String, Map, Dynamic>(
145 - &plugin.ast,
146 - plugin_id,
147 - "parse",
148 - file_path.to_string(),
149 - options_map,
150 - )?;
151 -
152 - // Log any messages
153 - let logs = PluginApi::get_logs();
154 - for (level, msg) in &logs {
155 - match level.as_str() {
156 - "info" => tracing::info!("[{}] {}", plugin_id, msg),
157 - "warn" => tracing::warn!("[{}] {}", plugin_id, msg),
158 - "error" => tracing::error!("[{}] {}", plugin_id, msg),
159 - _ => {}
160 - }
161 - }
162 -
163 - // Convert result
164 - dynamic_to_import_result(result, plugin_id)
165 - }
166 -
167 - /// Enables a plugin.
168 - #[tracing::instrument(skip_all)]
169 - pub fn enable_plugin(&mut self, plugin_id: &str) -> Result<()> {
170 - self.loader.enable_plugin(plugin_id)?;
171 - // Load the plugin
172 - let available_path = self.loader.available_dir().join(plugin_id);
173 - self.loader.load_plugin(plugin_id, &available_path)?;
174 - Ok(())
175 - }
176 -
177 - /// Disables a plugin.
178 - #[tracing::instrument(skip_all)]
179 - pub fn disable_plugin(&mut self, plugin_id: &str) -> Result<()> {
180 - self.loader.disable_plugin(plugin_id)
181 - }
182 -
183 - /// Reloads a plugin from disk.
184 - #[tracing::instrument(skip_all)]
185 - pub fn reload_plugin(&mut self, plugin_id: &str) -> Result<PluginMeta> {
186 - let loaded = self.loader.reload_plugin(plugin_id)?;
187 - Ok(loaded.meta)
188 - }
189 - }
190 -
191 - // Allow access to internal loaded plugins for iteration
192 - impl PluginRegistry {
193 - /// Iterates over loaded plugins.
194 - #[tracing::instrument(skip_all)]
195 - pub fn iter_loaded(&self) -> impl Iterator<Item = (&String, &LoadedPlugin)> {
196 - self.loader.loaded().iter()
197 - }
198 - }
199 -
200 - fn options_to_rhai_map(options: &ImportOptions) -> Map {
201 - let mut map = Map::new();
202 - map.insert("has_header".into(), Dynamic::from(options.has_header));
203 -
204 - if let Some(delimiter) = options.delimiter {
205 - map.insert("delimiter".into(), Dynamic::from(delimiter.to_string()));
206 - }
207 -
208 - if let Some(ref date_format) = options.date_format {
209 - map.insert("date_format".into(), Dynamic::from(date_format.clone()));
210 - }
211 -
212 - for (key, value) in &options.extra {
213 - map.insert(key.clone().into(), Dynamic::from(value.clone()));
214 - }
215 -
216 - map
217 - }
218 -
219 - #[cfg(test)]
220 - mod tests {
221 - use super::*;
222 - use std::path::Path;
223 - use crate::ImportEntityType;
224 - use tempfile::TempDir;
225 -
226 - fn create_csv_import_plugin(dir: &Path) {
227 - let plugin_dir = dir.join("available").join("csv-import");
228 - std::fs::create_dir_all(&plugin_dir).unwrap();
229 -
230 - let manifest = r#"
231 - [plugin]
232 - name = "CSV Import"
233 - version = "1.0.0"
234 - description = "Import tasks from CSV files"
235 -
236 - [plugin.type]
237 - kind = "import"
238 -
239 - [plugin.import]
240 - file_extensions = ["csv"]
241 - entity_types = ["task"]
242 -
243 - [plugin.capabilities]
244 - file_read = true
245 - database_write = true
246 - "#;
247 - std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
248 -
249 - let script = r#"
250 - fn describe() {
251 - #{
252 - name: "CSV Import",
253 - file_extensions: ["csv"]
254 - }
255 - }
256 -
257 - fn parse(file_path, options) {
258 - let content = goingson::read_file(file_path);
259 - let rows = goingson::parse_csv(content, options);
260 -
261 - let tasks = [];
262 - for row in rows {
263 - tasks.push(#{
264 - description: row.description,
265 - due: row.due,
266 - priority: row.priority
267 - });
268 - }
269 -
270 - goingson::task_result(tasks)
271 - }
272 - "#;
273 - std::fs::write(plugin_dir.join("main.rhai"), script).unwrap();
274 - }
275 -
276 - #[test]
277 - fn test_list_import_plugins() {
278 - let temp_dir = TempDir::new().unwrap();
279 - create_csv_import_plugin(temp_dir.path());
280 -
281 - let registry = PluginRegistry::new(temp_dir.path()).unwrap();
282 - let plugins = registry.list_import_plugins().unwrap();
283 -
284 - assert_eq!(plugins.len(), 1);
285 - assert_eq!(plugins[0].name, "CSV Import");
286 - }
287 -
288 - #[test]
289 - fn test_preview_import() {
290 - let temp_dir = TempDir::new().unwrap();
291 - create_csv_import_plugin(temp_dir.path());
292 -
293 - // Create test CSV file
294 - let csv_path = temp_dir.path().join("test.csv");
295 - std::fs::write(
296 - &csv_path,
297 - "description,due,priority\nBuy milk,2024-02-20,High\n",
298 - )
299 - .unwrap();
300 -
301 - let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
302 -
303 - // Enable the plugin
304 - registry.enable_plugin("csv-import").unwrap();
305 -
306 - // Preview import
307 - let result = registry
308 - .preview_import(
309 - "csv-import",
310 - csv_path.to_str().unwrap(),
311 - ImportOptions::default(),
312 - Vec::new(),
313 - )
314 - .unwrap();
315 -
316 - assert_eq!(result.entity_type, ImportEntityType::Task);
317 - assert!(!result.items.is_empty(), "Expected at least 1 parsed item, got {}", result.items.len());
318 - }
319 -
320 - // --- Helpers for additional plugin types ---
321 -
322 - /// Creates a JSON import plugin that handles .json files.
323 - fn create_json_import_plugin(dir: &Path) {
324 - let plugin_dir = dir.join("available").join("json-import");
325 - std::fs::create_dir_all(&plugin_dir).unwrap();
326 -
327 - let manifest = r#"
328 - [plugin]
329 - name = "JSON Import"
330 - version = "2.0.0"
331 - description = "Import tasks from JSON files"
332 -
333 - [plugin.type]
334 - kind = "import"
335 -
336 - [plugin.import]
337 - file_extensions = ["json"]
338 - entity_types = ["task"]
339 -
340 - [plugin.capabilities]
341 - file_read = true
342 - database_write = false
343 - "#;
344 - std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
345 -
346 - let script = r#"
347 - fn describe() {
348 - #{
349 - name: "JSON Import",
350 - file_extensions: ["json"]
351 - }
352 - }
353 -
354 - fn parse(file_path, options) {
355 - goingson::task_result([])
356 - }
357 - "#;
358 - std::fs::write(plugin_dir.join("main.rhai"), script).unwrap();
359 - }
360 -
361 - /// Creates a command plugin (not an import plugin).
362 - fn create_command_plugin(dir: &Path) {
363 - let plugin_dir = dir.join("available").join("my-command");
364 - std::fs::create_dir_all(&plugin_dir).unwrap();
365 -
366 - let manifest = r#"
367 - [plugin]
368 - name = "My Command"
369 - version = "1.0.0"
370 - description = "A custom command plugin"
371 -
372 - [plugin.type]
373 - kind = "command"
374 -
375 - [plugin.capabilities]
376 - "#;
377 - std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
378 -
379 - let script = r#"
380 - fn describe() {
381 - #{
382 - name: "My Command"
383 - }
384 - }
385 -
386 - fn execute(args) {
387 - 42
388 - }
389 - "#;
390 - std::fs::write(plugin_dir.join("main.rhai"), script).unwrap();
391 - }
392 -
393 - // --- list_enabled_import_plugins ---
394 -
395 - #[test]
396 - fn list_enabled_import_plugins_empty_when_none_loaded() {
397 - let temp_dir = TempDir::new().unwrap();
398 - create_csv_import_plugin(temp_dir.path());
399 -
400 - // Registry exists but no plugins have been enabled/loaded
401 - let registry = PluginRegistry::new(temp_dir.path()).unwrap();
402 - let enabled = registry.list_enabled_import_plugins();
403 - assert!(enabled.is_empty());
404 - }
405 -
406 - #[test]
407 - fn list_enabled_import_plugins_returns_loaded_imports() {
408 - let temp_dir = TempDir::new().unwrap();
409 - create_csv_import_plugin(temp_dir.path());
410 - create_json_import_plugin(temp_dir.path());
411 -
412 - let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
413 - registry.enable_plugin("csv-import").unwrap();
414 - registry.enable_plugin("json-import").unwrap();
415 -
416 - let enabled = registry.list_enabled_import_plugins();
417 - assert_eq!(enabled.len(), 2);
418 -
419 - let names: Vec<&str> = enabled.iter().map(|p| p.name.as_str()).collect();
420 - assert!(names.contains(&"CSV Import"));
421 - assert!(names.contains(&"JSON Import"));
422 - }
423 -
424 - #[test]
425 - fn list_enabled_import_plugins_excludes_non_import() {
426 - let temp_dir = TempDir::new().unwrap();
427 - create_csv_import_plugin(temp_dir.path());
428 - create_command_plugin(temp_dir.path());
429 -
430 - let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
431 - registry.enable_plugin("csv-import").unwrap();
432 - registry.enable_plugin("my-command").unwrap();
433 -
434 - let enabled = registry.list_enabled_import_plugins();
435 - assert_eq!(enabled.len(), 1);
436 - assert_eq!(enabled[0].name, "CSV Import");
437 - }
438 -
439 - // --- get_plugins_for_extension ---
440 -
441 - #[test]
442 - fn get_plugins_for_extension_matches() {
443 - let temp_dir = TempDir::new().unwrap();
444 - create_csv_import_plugin(temp_dir.path());
445 -
446 - let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
447 - registry.enable_plugin("csv-import").unwrap();
448 -
449 - let plugins = registry.get_plugins_for_extension("csv");
450 - assert_eq!(plugins.len(), 1);
451 - assert_eq!(plugins[0].name, "CSV Import");
452 - }
453 -
454 - #[test]
455 - fn get_plugins_for_extension_case_insensitive() {
456 - let temp_dir = TempDir::new().unwrap();
457 - create_csv_import_plugin(temp_dir.path());
458 -
459 - let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
460 - registry.enable_plugin("csv-import").unwrap();
461 -
462 - let plugins = registry.get_plugins_for_extension("CSV");
463 - assert_eq!(plugins.len(), 1);
464 - assert_eq!(plugins[0].name, "CSV Import");
465 - }
466 -
467 - #[test]
468 - fn get_plugins_for_extension_no_match() {
469 - let temp_dir = TempDir::new().unwrap();
470 - create_csv_import_plugin(temp_dir.path());
471 -
472 - let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
473 - registry.enable_plugin("csv-import").unwrap();
474 -
475 - let plugins = registry.get_plugins_for_extension("xlsx");
476 - assert!(plugins.is_empty());
477 - }
478 -
479 - #[test]
480 - fn get_plugins_for_extension_multiple_plugins_same_ext() {
481 - let temp_dir = TempDir::new().unwrap();
482 - create_csv_import_plugin(temp_dir.path());
483 -
484 - // Create a second CSV plugin
485 - let plugin_dir = temp_dir.path().join("available").join("csv-import-v2");
486 - std::fs::create_dir_all(&plugin_dir).unwrap();
487 - let manifest = r#"
488 - [plugin]
489 - name = "CSV Import V2"
490 - version = "2.0.0"
491 - description = "Another CSV importer"
492 -
493 - [plugin.type]
494 - kind = "import"
495 -
496 - [plugin.import]
497 - file_extensions = ["csv", "tsv"]
498 - entity_types = ["task"]
499 -
500 - [plugin.capabilities]
Lines truncated
@@ -2,7 +2,7 @@
2 2
3 3 Email, calendar, tasks in one place. Project management for individuals and small teams.
4 4
5 - A Rust-based productivity application built with Tauri 2 (Rust backend + Vanilla JS frontend), SQLite with sqlx 0.8, and a "Neobrute" design aesthetic. 3 crates: `core` (domain models), `db-sqlite` (repository), `plugin-runtime` (Rhai plugins).
5 + A Rust-based productivity application built with Tauri 2 (Rust backend + Vanilla JS frontend), SQLite with sqlx 0.8, and a "Neobrute" design aesthetic. 2 crates: `core` (domain models), `db-sqlite` (repository).
6 6
7 7 ## High-Level Overview
8 8
@@ -31,9 +31,6 @@ A Rust-based productivity application built with Tauri 2 (Rust backend + Vanilla
31 31 │ sqlite │
32 32 │ (Desktop) │
33 33 └──────────────────┘
34 -
35 - Additional crates:
36 - plugin-runtime Rhai plugin system (import plugins)
37 34 ```
38 35
39 36 ## Workspace Structure
@@ -42,8 +39,7 @@ Additional crates:
42 39 goingson/
43 40 ├── crates/
44 41 │ ├── core/ # Domain models, traits, business logic
45 - │ ├── db-sqlite/ # SQLite repository implementations
46 - │ └── plugin-runtime/ # Rhai plugin system
42 + │ └── db-sqlite/ # SQLite repository implementations
47 43 ├── src-tauri/ # Tauri desktop app (single-user)
48 44 └── migrations/
49 45 └── sqlite/ # SQLite schema migrations (33 files)
@@ -53,10 +49,8 @@ goingson/
53 49
54 50 ```
55 51 goingson-desktop (src-tauri)
56 - ├── goingson-db-sqlite
57 - │ └── goingson-core
58 - └── plugin-runtime
59 - └── goingson-core (for shared types)
52 + └── goingson-db-sqlite
53 + └── goingson-core
60 54 ```
61 55
62 56 ## Core Crate (`crates/core/`)
@@ -165,7 +159,7 @@ src/
165 159 ├── weekly_review.rs # Weekly review workflow
166 160 ├── saved_views.rs # Custom filter views
167 161 ├── milestone.rs # Milestone CRUD and reordering
168 - ├── plugin.rs # Plugin registry, hot-reload, import
162 + ├── import.rs # Native CSV/TSV import (preview + execute)
169 163 ├── oauth.rs # OAuth2 flows (Fastmail, Google, Microsoft)
170 164 ├── export.rs # JSON, CSV, ICS export; backup/restore
171 165 ├── sync.rs # Cloud sync via SyncKit
@@ -1,288 +0,0 @@
1 - // CSV Import Plugin for GoingsOn
2 - //
3 - // Imports tasks, projects, or events from CSV files.
4 - // Supports common field mappings and flexible date parsing.
5 -
6 - // Plugin metadata
7 - fn describe() {
8 - #{
9 - name: "CSV Import",
10 - file_extensions: ["csv", "tsv"],
11 - entity_types: ["task", "project", "event"]
12 - }
13 - }
14 -
15 - // Parse a CSV file and return items for import
16 - fn parse(file_path, options) {
17 - // Read and parse CSV
18 - let content = goingson::read_file(file_path);
19 - let rows = goingson::parse_csv(content, options);
20 -
21 - if rows.len() == 0 {
22 - goingson::log_warn("CSV file is empty");
23 - return goingson::task_result([]);
24 - }
25 -
26 - // Auto-detect entity type from columns
27 - let first_row = rows[0];
28 - let columns = [];
29 - for key in first_row.keys() {
30 - columns.push(key.to_lower());
31 - }
32 -
33 - let entity_type = detect_entity_type(columns);
34 - goingson::log_info(`Detected entity type: ${entity_type}`);
35 -
36 - // Parse based on entity type
37 - switch entity_type {
38 - "task" => parse_tasks(rows),
39 - "project" => parse_projects(rows),
40 - "event" => parse_events(rows),
41 - _ => {
42 - goingson::log_error("Could not determine entity type from columns");
43 - goingson::task_result([])
44 - }
45 - }
46 - }
47 -
48 - // Detect entity type from column names
49 - fn detect_entity_type(columns) {
50 - // Event indicators: start/end times
51 - if columns.contains("start") || columns.contains("start_time") || columns.contains("start_date") {
52 - return "event";
53 - }
54 -
55 - // Project indicators
56 - if columns.contains("project_type") || (columns.contains("name") && !columns.contains("description")) {
57 - return "project";
58 - }
59 -
60 - // Default to task
61 - "task"
62 - }
63 -
64 - // Parse rows as tasks
65 - fn parse_tasks(rows) {
66 - let items = [];
67 - let row_num = 0;
68 -
69 - for row in rows {
70 - row_num += 1;
71 -
72 - // Get description (required)
73 - let description = get_field(row, ["description", "task", "title", "name", "subject"]);
74 - if description == "" {
75 - goingson::log_warn(`Row ${row_num}: Missing description, skipping`);
76 - continue;
77 - }
78 -
79 - // Get optional fields
80 - let due = get_field(row, ["due", "due_date", "deadline", "date"]);
81 - let priority = normalize_priority(get_field(row, ["priority", "pri", "importance"]));
82 - let status = normalize_status(get_field(row, ["status", "state"]));
83 - let project_name = get_field(row, ["project", "project_name", "category"]);
84 - let tags = parse_tags(get_field(row, ["tags", "labels", "categories"]));
85 - let notes = get_field(row, ["notes", "note", "comments", "body"]);
86 -
87 - // Parse due date if present
88 - if due != "" {
89 - due = goingson::parse_date(due);
90 - }
91 -
92 - items.push(#{
93 - description: description,
94 - due: due,
95 - priority: priority,
96 - status: status,
97 - project_name: project_name,
98 - tags: tags,
99 - notes: notes
100 - });
101 - }
102 -
103 - goingson::log_info(`Parsed ${items.len()} tasks`);
104 - goingson::task_result(items)
105 - }
106 -
107 - // Parse rows as projects
108 - fn parse_projects(rows) {
109 - let items = [];
110 - let row_num = 0;
111 -
112 - for row in rows {
113 - row_num += 1;
114 -
115 - // Get name (required)
116 - let name = get_field(row, ["name", "project", "title"]);
117 - if name == "" {
118 - goingson::log_warn(`Row ${row_num}: Missing name, skipping`);
119 - continue;
120 - }
121 -
122 - // Get optional fields
123 - let description = get_field(row, ["description", "desc", "notes"]);
124 - let project_type = normalize_project_type(get_field(row, ["type", "project_type", "category"]));
125 - let status = normalize_project_status(get_field(row, ["status", "state"]));
126 -
127 - items.push(#{
128 - name: name,
129 - description: description,
130 - project_type: project_type,
131 - status: status
132 - });
133 - }
134 -
135 - goingson::log_info(`Parsed ${items.len()} projects`);
136 - goingson::project_result(items)
137 - }
138 -
139 - // Parse rows as events
140 - fn parse_events(rows) {
141 - let items = [];
142 - let row_num = 0;
143 -
144 - for row in rows {
145 - row_num += 1;
146 -
147 - // Get title (required)
148 - let title = get_field(row, ["title", "name", "event", "subject", "summary"]);
149 - if title == "" {
150 - goingson::log_warn(`Row ${row_num}: Missing title, skipping`);
151 - continue;
152 - }
153 -
154 - // Get start time (required)
155 - let start = get_field(row, ["start", "start_time", "start_date", "date", "when"]);
156 - if start == "" {
157 - goingson::log_warn(`Row ${row_num}: Missing start time, skipping`);
158 - continue;
159 - }
160 - start = goingson::parse_date(start);
161 -
162 - // Get optional fields
163 - let end = get_field(row, ["end", "end_time", "end_date"]);
164 - if end != "" {
165 - end = goingson::parse_date(end);
166 - }
167 -
168 - let location = get_field(row, ["location", "place", "venue", "where"]);
169 - let description = get_field(row, ["description", "notes", "body", "details"]);
170 - let project_name = get_field(row, ["project", "project_name", "category"]);
171 -
172 - items.push(#{
173 - title: title,
174 - start: start,
175 - end: end,
176 - location: location,
177 - description: description,
178 - project_name: project_name
179 - });
180 - }
181 -
182 - goingson::log_info(`Parsed ${items.len()} events`);
183 - goingson::event_result(items)
184 - }
185 -
186 - // Get first matching field from row
187 - fn get_field(row, field_names) {
188 - for name in field_names {
189 - if row.contains(name) {
190 - let value = row[name];
191 - if value != () && value != "" {
192 - return value.to_string().trim();
193 - }
194 - }
195 - // Also check lowercase version
196 - let lower_name = name.to_lower();
197 - if row.contains(lower_name) {
198 - let value = row[lower_name];
199 - if value != () && value != "" {
200 - return value.to_string().trim();
201 - }
202 - }
203 - }
204 - ""
205 - }
206 -
207 - // Normalize priority value
208 - fn normalize_priority(value) {
209 - if value == "" { return (); }
210 -
211 - let lower = value.to_lower();
212 - switch lower {
213 - "high" | "1" | "h" | "urgent" | "critical" => "High",
214 - "low" | "3" | "l" | "minor" => "Low",
215 - _ => "Medium"
216 - }
217 - }
218 -
219 - // Normalize task status
220 - fn normalize_status(value) {
221 - if value == "" { return (); }
222 -
223 - let lower = value.to_lower();
224 - switch lower {
225 - "done" | "complete" | "completed" | "finished" | "closed" => "Done",
226 - "in progress" | "inprogress" | "started" | "working" | "active" => "InProgress",
227 - _ => "Pending"
228 - }
229 - }
230 -
231 - // Normalize project type
232 - fn normalize_project_type(value) {
233 - if value == "" { return (); }
234 -
235 - let lower = value.to_lower();
236 - switch lower {
237 - "job" | "work" | "employment" => "Job",
238 - "side project" | "sideproject" | "personal" | "hobby" => "SideProject",
239 - "company" | "business" | "startup" => "Company",
240 - "essay" | "writing" => "Essay",
241 - "article" | "blog" | "post" => "Article",
242 - "painting" | "art" | "visual" => "Painting",
243 - _ => "Other"
244 - }
245 - }
246 -
247 - // Normalize project status
248 - fn normalize_project_status(value) {
249 - if value == "" { return (); }
250 -
251 - let lower = value.to_lower();
252 - switch lower {
253 - "active" | "current" | "ongoing" => "Active",
254 - "on hold" | "onhold" | "paused" | "waiting" => "OnHold",
255 - "completed" | "done" | "finished" => "Completed",
256 - "archived" | "inactive" | "closed" => "Archived",
257 - _ => "Active"
258 - }
259 - }
260 -
261 - // Parse tags from string
262 - fn parse_tags(value) {
263 - if value == "" { return []; }
264 -
265 - // Split by comma, semicolon, or pipe
266 - let tags = [];
267 - let current = "";
268 -
269 - for char in value.chars() {
270 - if char == ',' || char == ';' || char == '|' {
271 - let tag = current.trim();
272 - if tag != "" {
273 - tags.push(tag);
274 - }
275 - current = "";
276 - } else {
277 - current += char;
278 - }
279 - }
280 -
281 - // Don't forget the last tag
282 - let tag = current.trim();
283 - if tag != "" {
284 - tags.push(tag);
285 - }
286 -
287 - tags
288 - }
@@ -1,18 +0,0 @@
1 - [plugin]
2 - name = "CSV Import"
3 - version = "1.0.0"
4 - description = "Import tasks, projects, or events from CSV files"
5 - author = "GoingsOn"
6 - min_app_version = "0.2.0"
7 -
8 - [plugin.type]
9 - kind = "import"
10 -
11 - [plugin.import]
12 - file_extensions = ["csv", "tsv"]
13 - entity_types = ["task", "project", "event"]
14 -
15 - [plugin.capabilities]
16 - file_read = true
17 - database_write = true
18 - network = false
@@ -18,7 +18,6 @@ tauri-build = { workspace = true }
18 18 [dependencies]
19 19 goingson-core = { workspace = true }
20 20 goingson-db-sqlite = { workspace = true }
21 - goingson-plugin-runtime = { workspace = true }
22 21 synckit-client = { path = "../../../MNW/shared/synckit-client" }
23 22
24 23 # Tauri