max / goingson
30 files changed,
+943 insertions,
-3983 deletions
| @@ -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" |
| @@ -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" } |
| @@ -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 |