max / goingson
62 files changed,
+2924 insertions,
-461 deletions
| @@ -0,0 +1,211 @@ | |||
| 1 | + | //! Human-readable date/time formatting utilities. | |
| 2 | + | //! | |
| 3 | + | //! Provides functions that turn absolute timestamps into relative display | |
| 4 | + | //! strings for the UI (e.g. "today", "2d ago", "+3d", "Just now"). | |
| 5 | + | //! | |
| 6 | + | //! All functions accept an explicit `now` parameter so they are | |
| 7 | + | //! deterministic and easy to test. | |
| 8 | + | ||
| 9 | + | use chrono::{DateTime, Local, Utc}; | |
| 10 | + | ||
| 11 | + | /// Formats a date relative to now, bidirectional: "2d ago", "today", "tomorrow", "+3d", "Mar 15". | |
| 12 | + | /// | |
| 13 | + | /// Used for task due dates and similar bidirectional relative displays. | |
| 14 | + | pub fn format_relative_date(dt: DateTime<Utc>, now: DateTime<Utc>) -> String { | |
| 15 | + | let diff_days = (dt.date_naive() - now.date_naive()).num_days(); | |
| 16 | + | if diff_days < -1 { | |
| 17 | + | format!("{}d ago", -diff_days) | |
| 18 | + | } else if diff_days == -1 { | |
| 19 | + | "1d ago".to_string() | |
| 20 | + | } else if diff_days == 0 { | |
| 21 | + | "today".to_string() | |
| 22 | + | } else if diff_days == 1 { | |
| 23 | + | "tomorrow".to_string() | |
| 24 | + | } else if diff_days < 7 { | |
| 25 | + | format!("+{}d", diff_days) | |
| 26 | + | } else { | |
| 27 | + | dt.format("%b %d").to_string() | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /// Formats a future date relative to now: "today", "tomorrow", "+3d", "Mar 15". | |
| 32 | + | /// | |
| 33 | + | /// Used for snooze-until displays where the date is always in the future. | |
| 34 | + | pub fn format_relative_future(dt: DateTime<Utc>, now: DateTime<Utc>) -> String { | |
| 35 | + | let diff_days = (dt.date_naive() - now.date_naive()).num_days(); | |
| 36 | + | if diff_days == 0 { | |
| 37 | + | "today".to_string() | |
| 38 | + | } else if diff_days == 1 { | |
| 39 | + | "tomorrow".to_string() | |
| 40 | + | } else if diff_days < 7 { | |
| 41 | + | format!("+{}d", diff_days) | |
| 42 | + | } else { | |
| 43 | + | dt.format("%b %d").to_string() | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /// Formats a past timestamp as elapsed time: "Just now", "2h ago", "3d ago", "Mar 15". | |
| 48 | + | /// | |
| 49 | + | /// Used for email received_at displays. | |
| 50 | + | pub fn format_elapsed_time(dt: DateTime<Utc>, now: DateTime<Utc>) -> String { | |
| 51 | + | let diff = now.signed_duration_since(dt); | |
| 52 | + | let hours = diff.num_hours(); | |
| 53 | + | if hours < 1 { | |
| 54 | + | "Just now".to_string() | |
| 55 | + | } else if hours < 24 { | |
| 56 | + | format!("{}h ago", hours) | |
| 57 | + | } else { | |
| 58 | + | let days = diff.num_days(); | |
| 59 | + | if days < 7 { | |
| 60 | + | format!("{}d ago", days) | |
| 61 | + | } else { | |
| 62 | + | dt.with_timezone(&Local).format("%b %d").to_string() | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | #[cfg(test)] | |
| 68 | + | mod tests { | |
| 69 | + | use super::*; | |
| 70 | + | use chrono::{Duration, NaiveDate, NaiveTime, TimeZone}; | |
| 71 | + | ||
| 72 | + | fn utc(year: i32, month: u32, day: u32, hour: u32) -> DateTime<Utc> { | |
| 73 | + | let date = NaiveDate::from_ymd_opt(year, month, day).unwrap(); | |
| 74 | + | let time = NaiveTime::from_hms_opt(hour, 0, 0).unwrap(); | |
| 75 | + | Utc.from_utc_datetime(&date.and_time(time)) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // ---- format_relative_date ---- | |
| 79 | + | ||
| 80 | + | #[test] | |
| 81 | + | fn relative_date_today() { | |
| 82 | + | let now = utc(2026, 4, 15, 12); | |
| 83 | + | let dt = utc(2026, 4, 15, 8); | |
| 84 | + | assert_eq!(format_relative_date(dt, now), "today"); | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | #[test] | |
| 88 | + | fn relative_date_tomorrow() { | |
| 89 | + | let now = utc(2026, 4, 15, 12); | |
| 90 | + | let dt = utc(2026, 4, 16, 9); | |
| 91 | + | assert_eq!(format_relative_date(dt, now), "tomorrow"); | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | #[test] | |
| 95 | + | fn relative_date_yesterday() { | |
| 96 | + | let now = utc(2026, 4, 15, 12); | |
| 97 | + | let dt = utc(2026, 4, 14, 12); | |
| 98 | + | assert_eq!(format_relative_date(dt, now), "1d ago"); | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | #[test] | |
| 102 | + | fn relative_date_multiple_days_ago() { | |
| 103 | + | let now = utc(2026, 4, 15, 12); | |
| 104 | + | let dt = utc(2026, 4, 12, 12); | |
| 105 | + | assert_eq!(format_relative_date(dt, now), "3d ago"); | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | #[test] | |
| 109 | + | fn relative_date_few_days_future() { | |
| 110 | + | let now = utc(2026, 4, 15, 12); | |
| 111 | + | let dt = utc(2026, 4, 18, 12); | |
| 112 | + | assert_eq!(format_relative_date(dt, now), "+3d"); | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | #[test] | |
| 116 | + | fn relative_date_far_future() { | |
| 117 | + | let now = utc(2026, 4, 15, 12); | |
| 118 | + | let dt = utc(2026, 5, 10, 12); | |
| 119 | + | assert_eq!(format_relative_date(dt, now), "May 10"); | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | #[test] | |
| 123 | + | fn relative_date_boundary_six_days() { | |
| 124 | + | let now = utc(2026, 4, 15, 12); | |
| 125 | + | let dt = utc(2026, 4, 21, 12); | |
| 126 | + | assert_eq!(format_relative_date(dt, now), "+6d"); | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | #[test] | |
| 130 | + | fn relative_date_boundary_seven_days() { | |
| 131 | + | let now = utc(2026, 4, 15, 12); | |
| 132 | + | let dt = utc(2026, 4, 22, 12); | |
| 133 | + | assert_eq!(format_relative_date(dt, now), "Apr 22"); | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // ---- format_relative_future ---- | |
| 137 | + | ||
| 138 | + | #[test] | |
| 139 | + | fn relative_future_today() { | |
| 140 | + | let now = utc(2026, 4, 15, 12); | |
| 141 | + | let dt = utc(2026, 4, 15, 17); | |
| 142 | + | assert_eq!(format_relative_future(dt, now), "today"); | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | #[test] | |
| 146 | + | fn relative_future_tomorrow() { | |
| 147 | + | let now = utc(2026, 4, 15, 12); | |
| 148 | + | let dt = utc(2026, 4, 16, 9); | |
| 149 | + | assert_eq!(format_relative_future(dt, now), "tomorrow"); | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | #[test] | |
| 153 | + | fn relative_future_few_days() { | |
| 154 | + | let now = utc(2026, 4, 15, 12); | |
| 155 | + | let dt = utc(2026, 4, 19, 9); | |
| 156 | + | assert_eq!(format_relative_future(dt, now), "+4d"); | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | #[test] | |
| 160 | + | fn relative_future_far() { | |
| 161 | + | let now = utc(2026, 4, 15, 12); | |
| 162 | + | let dt = utc(2026, 6, 1, 9); | |
| 163 | + | assert_eq!(format_relative_future(dt, now), "Jun 01"); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // ---- format_elapsed_time ---- | |
| 167 | + | ||
| 168 | + | #[test] | |
| 169 | + | fn elapsed_just_now() { | |
| 170 | + | let now = utc(2026, 4, 15, 12); | |
| 171 | + | let dt = now - Duration::minutes(30); | |
| 172 | + | assert_eq!(format_elapsed_time(dt, now), "Just now"); | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | #[test] | |
| 176 | + | fn elapsed_hours() { | |
| 177 | + | let now = utc(2026, 4, 15, 12); | |
| 178 | + | let dt = now - Duration::hours(5); | |
| 179 | + | assert_eq!(format_elapsed_time(dt, now), "5h ago"); | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | #[test] | |
| 183 | + | fn elapsed_days() { | |
| 184 | + | let now = utc(2026, 4, 15, 12); | |
| 185 | + | let dt = now - Duration::days(3); | |
| 186 | + | assert_eq!(format_elapsed_time(dt, now), "3d ago"); | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | #[test] | |
| 190 | + | fn elapsed_weeks_shows_date() { | |
| 191 | + | let now = utc(2026, 4, 15, 12); | |
| 192 | + | let dt = now - Duration::days(14); | |
| 193 | + | // Exact format depends on local timezone, but should contain month abbreviation | |
| 194 | + | let result = format_elapsed_time(dt, now); | |
| 195 | + | assert!(result.contains("Apr") || result.contains("Mar"), "Expected month abbrev, got: {result}"); | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | #[test] | |
| 199 | + | fn elapsed_boundary_23h() { | |
| 200 | + | let now = utc(2026, 4, 15, 12); | |
| 201 | + | let dt = now - Duration::hours(23); | |
| 202 | + | assert_eq!(format_elapsed_time(dt, now), "23h ago"); | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | #[test] | |
| 206 | + | fn elapsed_boundary_24h() { | |
| 207 | + | let now = utc(2026, 4, 15, 12); | |
| 208 | + | let dt = now - Duration::hours(24); | |
| 209 | + | assert_eq!(format_elapsed_time(dt, now), "1d ago"); | |
| 210 | + | } | |
| 211 | + | } |
| @@ -120,7 +120,6 @@ define_uuid_id!( | |||
| 120 | 120 | MonthlyReflectionId, | |
| 121 | 121 | SavedViewId, | |
| 122 | 122 | EmailAccountId, | |
| 123 | - | LlmSettingsId, | |
| 124 | 123 | UserId, | |
| 125 | 124 | ); | |
| 126 | 125 |
| @@ -33,6 +33,7 @@ | |||
| 33 | 33 | pub mod backup_restore; | |
| 34 | 34 | pub mod constants; | |
| 35 | 35 | pub mod contact; | |
| 36 | + | pub mod date_utils; | |
| 36 | 37 | pub mod day_planning; | |
| 37 | 38 | pub mod email_id; | |
| 38 | 39 | pub mod email_sync; | |
| @@ -56,16 +57,16 @@ pub use contact::{ | |||
| 56 | 57 | pub use error::CoreError; | |
| 57 | 58 | pub use id_types::{ | |
| 58 | 59 | AnnotationId, AttachmentId, ContactEmailId, ContactId, ContactPhoneId, CustomFieldId, | |
| 59 | - | EmailAccountId, EmailId, EventId, LlmSettingsId, MilestoneId, MonthlyGoalId, | |
| 60 | + | EmailAccountId, EmailId, EventId, MilestoneId, MonthlyGoalId, | |
| 60 | 61 | MonthlyReflectionId, ProjectId, SavedViewId, SocialHandleId, | |
| 61 | 62 | SubtaskId, SyncAccountId, TaskId, TimeSessionId, UserId, WeeklyReviewId, | |
| 62 | 63 | }; | |
| 63 | 64 | pub use models::{ | |
| 64 | 65 | Annotation, Attachment, BackupSettings, BlockType, CssClass, DbValue, Email, EmailAccount, | |
| 65 | - | EmailAuthType, EmailThread, Event, LlmContext, LlmProviderType, LlmSettings, Milestone, | |
| 66 | + | EmailAuthType, EmailThread, Event, Milestone, | |
| 66 | 67 | MilestoneStatus, MonthlyGoal, MonthlyGoalStatus, MonthlyReflection, | |
| 67 | 68 | AttachmentMeta, NewAttachment, NewBackupSettings, NewEmail, NewEmailWithTracking, NewEvent, NewEventBuilder, | |
| 68 | - | NewLlmSettings, NewMilestone, NewProject, NewSavedView, NewTask, NewTaskBuilder, Priority, | |
| 69 | + | NewMilestone, NewProject, NewSavedView, NewTask, NewTaskBuilder, Priority, | |
| 69 | 70 | Project, ParseableEnum, ProjectStatus, ProjectType, Recurrence, SavedView, SortDirection, | |
| 70 | 71 | SyncAccount, | |
| 71 | 72 | SortField, Subtask, Task, TaskFilterQuery, TaskSortColumn, TaskStatus, TimeSession, |
| @@ -26,7 +26,15 @@ pub fn calculate_next_due( | |||
| 26 | 26 | } | |
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | - | /// Add months to a DateTime, handling edge cases like month-end dates | |
| 29 | + | /// Add months to a DateTime, handling edge cases like month-end dates. | |
| 30 | + | /// | |
| 31 | + | /// Uses absolute month counting (year*12 + month) to add/subtract months, then | |
| 32 | + | /// clamps the day to the target month's length. Examples: | |
| 33 | + | /// Jan 31 + 1 month → Feb 28 (or 29 in a leap year) | |
| 34 | + | /// Mar 31 + 1 month → Apr 30 | |
| 35 | + | /// | |
| 36 | + | /// Preserves the original hour/minute/second. Falls back to the input datetime | |
| 37 | + | /// if the target date is ambiguous (e.g., DST gap via `with_ymd_and_hms`). | |
| 30 | 38 | fn add_months(dt: DateTime<Utc>, months: i32) -> DateTime<Utc> { | |
| 31 | 39 | ||
| 32 | 40 | let year = dt.year(); |
| @@ -54,7 +54,22 @@ pub fn calculate_urgency( | |||
| 54 | 54 | calculate_urgency_with_config(priority, status, due, created_at, tags, &config) | |
| 55 | 55 | } | |
| 56 | 56 | ||
| 57 | - | /// Calculate urgency with custom configuration | |
| 57 | + | /// Calculate urgency with custom configuration. | |
| 58 | + | /// | |
| 59 | + | /// Additive score from these factors (all configurable via `UrgencyConfig`): | |
| 60 | + | /// | |
| 61 | + | /// | Factor | Default | Condition | | |
| 62 | + | /// |-------------|---------|-------------------------------------------| | |
| 63 | + | /// | Priority | 3-7 | High=7, Medium=5, Low=3 | | |
| 64 | + | /// | Overdue | +12.0 | Due date in the past | | |
| 65 | + | /// | Due soon | 0-7.0 | Linear scale: 7 days out (0) → due (7.0) | | |
| 66 | + | /// | Age | 0-2.0 | Linear over 30 days, capped at 2.0 | | |
| 67 | + | /// | Started | +4.0 | Task status is Started | | |
| 68 | + | /// | "urgent" tag| +2.0 | Case-insensitive tag match | | |
| 69 | + | /// | |
| 70 | + | /// Result is rounded to 1 decimal place for stable sorting. | |
| 71 | + | /// Sub-day precision: hours are used internally so tasks due at 3pm sort | |
| 72 | + | /// differently from tasks due at 9am even on the same day. | |
| 58 | 73 | pub fn calculate_urgency_with_config( | |
| 59 | 74 | priority: &Priority, | |
| 60 | 75 | status: &TaskStatus, |
| @@ -225,6 +225,9 @@ fn build_is_filter_clauses(is_filters: &[IsFilter]) -> Vec<String> { | |||
| 225 | 225 | .collect() | |
| 226 | 226 | } | |
| 227 | 227 | ||
| 228 | + | /// Searches tasks via FTS5 MATCH with BM25 relevance ranking, or direct query when | |
| 229 | + | /// no search text is provided. Applies optional filters: `is:` status/date, project | |
| 230 | + | /// (by ID or name), priority, tag include/exclude, and date range on the due field. | |
| 228 | 231 | async fn search_tasks_fts( | |
| 229 | 232 | pool: &SqlitePool, | |
| 230 | 233 | user_id: &str, | |
| @@ -387,6 +390,9 @@ async fn search_tasks_fts( | |||
| 387 | 390 | .collect() | |
| 388 | 391 | } | |
| 389 | 392 | ||
| 393 | + | /// Searches emails by subject and body via FTS5 MATCH with BM25 ranking. | |
| 394 | + | /// Requires a search term (returns empty for filter-only queries). Applies optional | |
| 395 | + | /// project filter (by ID or name) and date range filter on the email date field. | |
| 390 | 396 | async fn search_emails_fts( | |
| 391 | 397 | pool: &SqlitePool, | |
| 392 | 398 | user_id: &str, | |
| @@ -498,6 +504,9 @@ async fn search_emails_fts( | |||
| 498 | 504 | .collect() | |
| 499 | 505 | } | |
| 500 | 506 | ||
| 507 | + | /// Searches projects by name and description via FTS5 MATCH with BM25 ranking. | |
| 508 | + | /// Requires a search term (returns empty for filter-only queries). Only applies | |
| 509 | + | /// date range filters (on `created_at`); skipped entirely when structured filters are present. | |
| 501 | 510 | async fn search_projects_fts( | |
| 502 | 511 | pool: &SqlitePool, | |
| 503 | 512 | user_id: &str, | |
| @@ -587,6 +596,9 @@ async fn search_projects_fts( | |||
| 587 | 596 | .collect() | |
| 588 | 597 | } | |
| 589 | 598 | ||
| 599 | + | /// Searches events via FTS5 MATCH with BM25 ranking, or direct query for filter-only | |
| 600 | + | /// searches. Supports time-based `is:` filters (today, tomorrow, this_week, overdue) on | |
| 601 | + | /// `start_time`, project filter (by ID or name), and date range on `start_time`. | |
| 590 | 602 | async fn search_events_fts( | |
| 591 | 603 | pool: &SqlitePool, | |
| 592 | 604 | user_id: &str, | |
| @@ -756,6 +768,9 @@ fn can_search_contacts(query: &SearchQuery) -> bool { | |||
| 756 | 768 | && query.project_name.is_none() | |
| 757 | 769 | } | |
| 758 | 770 | ||
| 771 | + | /// Searches contacts by display name and company via FTS5 MATCH with BM25 ranking. | |
| 772 | + | /// Requires a search term (returns empty for filter-only queries). No additional | |
| 773 | + | /// filters are applied; skipped entirely when structured filters are present. | |
| 759 | 774 | async fn search_contacts_fts( | |
| 760 | 775 | pool: &SqlitePool, | |
| 761 | 776 | user_id: &str, |
| @@ -235,4 +235,220 @@ mod tests { | |||
| 235 | 235 | panic!("Expected SafetyLimitExceeded error"); | |
| 236 | 236 | } | |
| 237 | 237 | } | |
| 238 | + | ||
| 239 | + | // ============ Plugin Lifecycle: Error and Recovery ============ | |
| 240 | + | ||
| 241 | + | #[test] | |
| 242 | + | fn script_error_returns_plugin_error_not_panic() { | |
| 243 | + | let engine = PluginEngine::new(); | |
| 244 | + | let ast = engine | |
| 245 | + | .compile( | |
| 246 | + | r#" | |
| 247 | + | fn hook_on_task_created(task) { | |
| 248 | + | throw "something went wrong in hook"; | |
| 249 | + | } | |
| 250 | + | "#, | |
| 251 | + | ) | |
| 252 | + | .unwrap(); | |
| 253 | + | ||
| 254 | + | let result: Result<Dynamic> = | |
| 255 | + | engine.call_fn_1(&ast, "my-hook-plugin", "hook_on_task_created", "task-1"); | |
| 256 | + | assert!(result.is_err()); | |
| 257 | + | ||
| 258 | + | match result.unwrap_err() { | |
| 259 | + | PluginError::ScriptError { plugin, message } => { | |
| 260 | + | assert_eq!(plugin, "my-hook-plugin"); | |
| 261 | + | assert!( | |
| 262 | + | message.contains("something went wrong in hook"), | |
| 263 | + | "Unexpected message: {}", | |
| 264 | + | message | |
| 265 | + | ); | |
| 266 | + | } | |
| 267 | + | other => panic!("Expected ScriptError, got {:?}", other), | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | #[test] | |
| 272 | + | fn plugin_recoverable_after_hook_error() { | |
| 273 | + | let engine = PluginEngine::new(); | |
| 274 | + | let ast = engine | |
| 275 | + | .compile( | |
| 276 | + | r#" | |
| 277 | + | fn on_task_created(task_id) { | |
| 278 | + | if task_id == "bad" { | |
| 279 | + | throw "invalid task"; | |
| 280 | + | } | |
| 281 | + | 42 | |
| 282 | + | } | |
| 283 | + | "#, | |
| 284 | + | ) | |
| 285 | + | .unwrap(); | |
| 286 | + | ||
| 287 | + | // First call errors | |
| 288 | + | let err_result: Result<Dynamic> = | |
| 289 | + | engine.call_fn_1(&ast, "recoverable", "on_task_created", "bad".to_string()); | |
| 290 | + | assert!(err_result.is_err()); | |
| 291 | + | match err_result.unwrap_err() { | |
| 292 | + | PluginError::ScriptError { .. } => {} | |
| 293 | + | other => panic!("Expected ScriptError, got {:?}", other), | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | // Second call with valid input succeeds -- engine did not poison itself | |
| 297 | + | let ok_result: i64 = | |
| 298 | + | engine.call_fn_1(&ast, "recoverable", "on_task_created", "good".to_string()).unwrap(); | |
| 299 | + | assert_eq!(ok_result, 42); | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | #[test] | |
| 303 | + | fn operation_limit_returns_safety_error_not_panic() { | |
| 304 | + | let limits = SafetyLimits { | |
| 305 | + | max_operations: 50, | |
| 306 | + | ..Default::default() | |
| 307 | + | }; | |
| 308 | + | let engine = PluginEngine::with_limits(limits); | |
| 309 | + | let ast = engine | |
| 310 | + | .compile( | |
| 311 | + | r#" | |
| 312 | + | fn expensive() { | |
| 313 | + | let x = 0; | |
| 314 | + | while x < 999999 { x += 1; } | |
| 315 | + | x | |
| 316 | + | } | |
| 317 | + | "#, | |
| 318 | + | ) | |
| 319 | + | .unwrap(); | |
| 320 | + | ||
| 321 | + | let result: Result<i64> = engine.call_fn(&ast, "expensive-plugin", "expensive"); | |
| 322 | + | assert!(result.is_err()); | |
| 323 | + | ||
| 324 | + | match result.unwrap_err() { | |
| 325 | + | PluginError::SafetyLimitExceeded { plugin, message } => { | |
| 326 | + | assert_eq!(plugin, "expensive-plugin"); | |
| 327 | + | assert!( | |
| 328 | + | message.contains("operations"), | |
| 329 | + | "Unexpected message: {}", | |
| 330 | + | message | |
| 331 | + | ); | |
| 332 | + | } | |
| 333 | + | other => panic!("Expected SafetyLimitExceeded, got {:?}", other), | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | #[test] | |
| 338 | + | fn recoverable_after_operation_limit() { | |
| 339 | + | let limits = SafetyLimits { | |
| 340 | + | max_operations: 50, | |
| 341 | + | ..Default::default() | |
| 342 | + | }; | |
| 343 | + | let engine = PluginEngine::with_limits(limits); | |
| 344 | + | let ast = engine | |
| 345 | + | .compile( | |
| 346 | + | r#" | |
| 347 | + | fn expensive() { | |
| 348 | + | let x = 0; | |
| 349 | + | while x < 999999 { x += 1; } | |
| 350 | + | x | |
| 351 | + | } | |
| 352 | + | fn cheap() { 1 } | |
| 353 | + | "#, | |
| 354 | + | ) | |
| 355 | + | .unwrap(); | |
| 356 | + | ||
| 357 | + | // expensive() blows the ops limit | |
| 358 | + | let err_result: Result<i64> = engine.call_fn(&ast, "ops-test", "expensive"); | |
| 359 | + | assert!(matches!( | |
| 360 | + | err_result, | |
| 361 | + | Err(PluginError::SafetyLimitExceeded { .. }) | |
| 362 | + | )); | |
| 363 | + | ||
| 364 | + | // cheap() should still work -- the engine resets its operation counter per call | |
| 365 | + | let ok_result: i64 = engine.call_fn(&ast, "ops-test", "cheap").unwrap(); | |
| 366 | + | assert_eq!(ok_result, 1); | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | #[test] | |
| 370 | + | fn compile_execute_multiple_functions_lifecycle() { | |
| 371 | + | let engine = PluginEngine::new(); | |
| 372 | + | ||
| 373 | + | // Compile a plugin-like script with describe + parse | |
| 374 | + | let ast = engine | |
| 375 | + | .compile( | |
| 376 | + | r#" | |
| 377 | + | fn describe() { | |
| 378 | + | #{ | |
| 379 | + | name: "lifecycle-test", | |
| 380 | + | file_extensions: ["csv"] | |
| 381 | + | } | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | fn parse(file_path, options) { | |
| 385 | + | let items = []; | |
| 386 | + | items.push(#{ description: "parsed from " + file_path }); | |
| 387 | + | goingson::task_result(items) | |
| 388 | + | } | |
| 389 | + | "#, | |
| 390 | + | ) | |
| 391 | + | .unwrap(); | |
| 392 | + | ||
| 393 | + | // Validate function signatures exist | |
| 394 | + | assert!(engine.has_function(&ast, "describe", 0)); | |
| 395 | + | assert!(engine.has_function(&ast, "parse", 2)); | |
| 396 | + | assert!(!engine.has_function(&ast, "execute", 1)); | |
| 397 | + | ||
| 398 | + | // Execute describe() | |
| 399 | + | let desc: Dynamic = engine.call_fn(&ast, "lifecycle", "describe").unwrap(); | |
| 400 | + | let map = desc.try_cast::<rhai::Map>().unwrap(); | |
| 401 | + | assert_eq!( | |
| 402 | + | map.get("name").unwrap().clone().into_string().unwrap(), | |
| 403 | + | "lifecycle-test" | |
| 404 | + | ); | |
| 405 | + | ||
| 406 | + | // Execute parse() | |
| 407 | + | let options = rhai::Map::new(); | |
| 408 | + | let result: Dynamic = engine | |
| 409 | + | .call_fn_2(&ast, "lifecycle", "parse", "/tmp/test.csv".to_string(), options) | |
| 410 | + | .unwrap(); | |
| 411 | + | let result_map = result.try_cast::<rhai::Map>().unwrap(); | |
| 412 | + | assert_eq!( | |
| 413 | + | result_map | |
| 414 | + | .get("entity_type") | |
| 415 | + | .unwrap() | |
| 416 | + | .clone() | |
| 417 | + | .into_string() | |
| 418 | + | .unwrap(), | |
| 419 | + | "task" | |
| 420 | + | ); | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | #[test] | |
| 424 | + | fn eval_is_disabled() { | |
| 425 | + | let engine = PluginEngine::new(); | |
| 426 | + | let result = engine.compile(r#"fn sneaky() { eval("1 + 1") }"#); | |
| 427 | + | // eval is disabled at the symbol level, so compilation should fail | |
| 428 | + | assert!(result.is_err()); | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | #[test] | |
| 432 | + | fn call_fn_with_runtime_error_returns_script_error() { | |
| 433 | + | let engine = PluginEngine::new(); | |
| 434 | + | let ast = engine | |
| 435 | + | .compile("fn divide(a, b) { a / b }") | |
| 436 | + | .unwrap(); | |
| 437 | + | ||
| 438 | + | // Division by zero should produce a ScriptError | |
| 439 | + | let result: Result<Dynamic> = engine.call_fn_2( | |
| 440 | + | &ast, | |
| 441 | + | "type-test", | |
| 442 | + | "divide", | |
| 443 | + | 42_i64, | |
| 444 | + | 0_i64, | |
| 445 | + | ); | |
| 446 | + | assert!(result.is_err()); | |
| 447 | + | match result.unwrap_err() { | |
| 448 | + | PluginError::ScriptError { plugin, .. } => { | |
| 449 | + | assert_eq!(plugin, "type-test"); | |
| 450 | + | } | |
| 451 | + | other => panic!("Expected ScriptError, got {:?}", other), | |
| 452 | + | } | |
| 453 | + | } | |
| 238 | 454 | } |
| @@ -14,7 +14,7 @@ use crate::manifest::PluginManifest; | |||
| 14 | 14 | use goingson_core::PluginMeta; | |
| 15 | 15 | ||
| 16 | 16 | /// A loaded plugin with its compiled script. | |
| 17 | - | #[derive(Clone)] | |
| 17 | + | #[derive(Debug, Clone)] | |
| 18 | 18 | pub struct LoadedPlugin { | |
| 19 | 19 | /// Plugin metadata from manifest. | |
| 20 | 20 | pub meta: PluginMeta, | |
| @@ -362,6 +362,7 @@ impl PluginLoader { | |||
| 362 | 362 | #[cfg(test)] | |
| 363 | 363 | mod tests { | |
| 364 | 364 | use super::*; | |
| 365 | + | use crate::engine::SafetyLimits; | |
| 365 | 366 | use tempfile::TempDir; | |
| 366 | 367 | ||
| 367 | 368 | fn create_test_plugin(dir: &Path, name: &str) { | |
| @@ -439,4 +440,529 @@ fn parse(file_path, options) { | |||
| 439 | 440 | loader.disable_plugin("test-plugin").unwrap(); | |
| 440 | 441 | assert!(!loader.is_enabled("test-plugin")); | |
| 441 | 442 | } | |
| 443 | + | ||
| 444 | + | // ============ Discovery: Non-Plugin Files ============ | |
| 445 | + | ||
| 446 | + | #[test] | |
| 447 | + | fn discover_available_skips_plain_files() { | |
| 448 | + | let temp_dir = TempDir::new().unwrap(); | |
| 449 | + | create_test_plugin(temp_dir.path(), "real-plugin"); | |
| 450 | + | ||
| 451 | + | // Drop non-plugin files and dirs into available/ | |
| 452 | + | let available = temp_dir.path().join("available"); | |
| 453 | + | std::fs::write(available.join("README.md"), "# Plugins directory").unwrap(); | |
| 454 | + | std::fs::write(available.join(".DS_Store"), "").unwrap(); | |
| 455 | + | std::fs::write(available.join("notes.txt"), "some notes").unwrap(); | |
| 456 | + | ||
| 457 | + | let loader = PluginLoader::new(temp_dir.path()).unwrap(); | |
| 458 | + | let plugins = loader.discover_available().unwrap(); | |
| 459 | + | ||
| 460 | + | // Only the real plugin directory should be discovered | |
| 461 | + | assert_eq!(plugins.len(), 1); | |
| 462 | + | assert_eq!(plugins[0].id, "real-plugin"); | |
| 463 | + | } | |
| 464 | + | ||
| 465 | + | #[test] | |
| 466 | + | fn discover_available_skips_dirs_without_manifest() { | |
| 467 | + | let temp_dir = TempDir::new().unwrap(); | |
| 468 | + | create_test_plugin(temp_dir.path(), "good-plugin"); | |
| 469 | + | ||
| 470 | + | // Create a directory that looks like a plugin but has no plugin.toml | |
| 471 | + | let no_manifest = temp_dir.path().join("available").join("incomplete"); | |
| 472 | + | std::fs::create_dir_all(&no_manifest).unwrap(); | |
| 473 | + | std::fs::write(no_manifest.join("main.rhai"), "fn describe() {}").unwrap(); | |
| 474 | + | ||
| 475 | + | let loader = PluginLoader::new(temp_dir.path()).unwrap(); | |
| 476 | + | let plugins = loader.discover_available().unwrap(); | |
| 477 | + | ||
| 478 | + | assert_eq!(plugins.len(), 1); | |
| 479 | + | assert_eq!(plugins[0].id, "good-plugin"); | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | #[test] | |
| 483 | + | fn discover_enabled_skips_regular_files_in_enabled_dir() { | |
| 484 | + | let temp_dir = TempDir::new().unwrap(); | |
| 485 | + | create_test_plugin(temp_dir.path(), "real-plugin"); | |
| 486 | + | ||
| 487 | + | // Enable the real plugin | |
| 488 | + | let loader = PluginLoader::new(temp_dir.path()).unwrap(); | |
| 489 | + | loader.enable_plugin("real-plugin").unwrap(); | |
| 490 | + | ||
| 491 | + | // Drop a stray file in the enabled/ directory | |
| 492 | + | let enabled_dir = temp_dir.path().join("enabled"); | |
| 493 | + | std::fs::write(enabled_dir.join("stray-file.txt"), "not a plugin").unwrap(); | |
| 494 | + | ||
| 495 | + | // Re-create loader to force fresh discovery | |
| 496 | + | let mut loader = PluginLoader::new(temp_dir.path()).unwrap(); | |
| 497 | + | let enabled = loader.discover_enabled().unwrap(); | |
| 498 | + | ||
| 499 | + | // Only the real symlinked plugin should be found | |
| 500 | + | assert_eq!(enabled.len(), 1); | |
| 501 | + | assert_eq!(enabled[0].id, "real-plugin"); | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | // ============ Corrupt Manifest During Load ============ | |
| 505 | + | ||
| 506 | + | #[test] | |
| 507 | + | fn load_plugin_with_corrupt_manifest_returns_error() { | |
| 508 | + | let temp_dir = TempDir::new().unwrap(); | |
| 509 | + | let plugin_dir = temp_dir.path().join("available").join("corrupt"); | |
| 510 | + | std::fs::create_dir_all(&plugin_dir).unwrap(); | |
| 511 | + | ||
| 512 | + | // Write invalid TOML as the manifest | |
| 513 | + | std::fs::write(plugin_dir.join("plugin.toml"), "{{{{ garbage !@#$").unwrap(); | |
| 514 | + | std::fs::write(plugin_dir.join("main.rhai"), "fn describe() {}").unwrap(); | |
| 515 | + | ||
| 516 | + | let mut loader = PluginLoader::new(temp_dir.path()).unwrap(); | |
| 517 | + | let result = loader.load_plugin("corrupt", &plugin_dir); | |
| 518 | + | match result { | |
| 519 | + | Err(PluginError::InvalidManifest(msg)) => { | |
| 520 | + | assert!(msg.contains("TOML parse error"), "Unexpected: {}", msg); | |
| 521 | + | } | |
| 522 | + | Err(other) => panic!("Expected InvalidManifest, got {:?}", other), | |
| 523 | + | Ok(_) => panic!("Expected error, got Ok"), | |
| 524 | + | } | |
| 525 | + | } | |
| 526 | + | ||
| 527 | + | #[test] | |
| 528 | + | fn load_plugin_missing_script_returns_error() { | |
| 529 | + | let temp_dir = TempDir::new().unwrap(); | |
| 530 | + | let plugin_dir = temp_dir.path().join("available").join("no-script"); | |
| 531 | + | std::fs::create_dir_all(&plugin_dir).unwrap(); | |
| 532 | + | ||
| 533 | + | // Valid manifest but no main.rhai | |
| 534 | + | let manifest = r#" | |
| 535 | + | [plugin] | |
| 536 | + | name = "no-script" | |
| 537 | + | version = "1.0.0" | |
| 538 | + | description = "Missing script" | |
| 539 | + | ||
| 540 | + | [plugin.type] | |
| 541 | + | kind = "command" | |
| 542 | + | ||
| 543 | + | [plugin.capabilities] | |
| 544 | + | "#; | |
| 545 | + | std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap(); | |
| 546 | + | ||
| 547 | + | let mut loader = PluginLoader::new(temp_dir.path()).unwrap(); | |
| 548 | + | let result = loader.load_plugin("no-script", &plugin_dir); | |
| 549 | + | match result { | |
| 550 | + | Err(PluginError::FileError(msg)) => { | |
| 551 | + | assert!(msg.contains("missing main.rhai"), "Unexpected: {}", msg); | |
| 552 | + | } | |
| 553 | + | Err(other) => panic!("Expected FileError, got {:?}", other), | |
| 554 | + | Ok(_) => panic!("Expected error, got Ok"), | |
| 555 | + | } | |
| 556 | + | } | |
| 557 | + | ||
| 558 | + | // ============ Reload (Hot-Reload) Edge Cases ============ | |
| 559 | + | ||
| 560 | + | #[test] | |
| 561 | + | fn reload_picks_up_modified_script() { | |
| 562 | + | let temp_dir = TempDir::new().unwrap(); | |
| 563 | + | create_test_plugin(temp_dir.path(), "hot-reload"); | |
| 564 | + | ||
| 565 | + | let plugin_dir = temp_dir.path().join("available").join("hot-reload"); | |
| 566 | + | let mut loader = PluginLoader::new(temp_dir.path()).unwrap(); | |
| 567 | + | loader.load_plugin("hot-reload", &plugin_dir).unwrap(); | |
| 568 | + | ||
| 569 | + | // Verify original script has the parse function | |
| 570 | + | let original = loader.get_plugin("hot-reload").unwrap(); | |
| 571 | + | assert!(loader.engine().has_function(&original.ast, "parse", 2)); | |
| 572 | + | ||
| 573 | + | // Update the script with different content (still valid) | |
| 574 | + | let updated_script = r#" | |
| 575 | + | fn describe() { | |
| 576 | + | #{ | |
| 577 | + | name: "Updated", | |
| 578 | + | file_extensions: ["csv"] | |
| 579 | + | } | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | fn parse(file_path, options) { | |
| 583 | + | goingson::task_result([#{description: "updated item"}]) | |
| 584 | + | } | |
| 585 | + | "#; | |
| 586 | + | std::fs::write(plugin_dir.join("main.rhai"), updated_script).unwrap(); | |
| 587 | + | ||
| 588 | + | // Reload and verify the AST is fresh (new script compiled) | |
| 589 | + | let reloaded = loader.reload_plugin("hot-reload").unwrap(); | |
| 590 | + | assert_eq!(reloaded.meta.name, "hot-reload"); | |
| 591 | + | ||
| 592 | + | // Call describe() on the reloaded AST to verify the new code runs | |
| 593 | + | let engine = loader.engine(); | |
| 594 | + | let desc: rhai::Dynamic = engine.call_fn(&reloaded.ast, "hot-reload", "describe").unwrap(); | |
| 595 | + | let map = desc.try_cast::<rhai::Map>().unwrap(); | |
| 596 | + | assert_eq!( | |
| 597 | + | map.get("name").unwrap().clone().into_string().unwrap(), | |
| 598 | + | "Updated" | |
| 599 | + | ); | |
| 600 | + | } | |
| 601 | + | ||
| 602 | + | #[test] | |
| 603 | + | fn reload_with_now_invalid_script_returns_error() { | |
| 604 | + | let temp_dir = TempDir::new().unwrap(); | |
| 605 | + | create_test_plugin(temp_dir.path(), "break-later"); | |
| 606 | + | ||
| 607 | + | let plugin_dir = temp_dir.path().join("available").join("break-later"); | |
| 608 | + | let mut loader = PluginLoader::new(temp_dir.path()).unwrap(); | |
| 609 | + | loader.load_plugin("break-later", &plugin_dir).unwrap(); | |
| 610 | + | ||
| 611 | + | // Overwrite with a syntactically invalid script | |
| 612 | + | std::fs::write(plugin_dir.join("main.rhai"), "fn broken( { }").unwrap(); | |
| 613 | + | ||
| 614 | + | let result = loader.reload_plugin("break-later"); | |
| 615 | + | assert!(result.is_err()); | |
| 616 | + | } | |
| 617 | + | ||
| 618 | + | #[test] | |
| 619 | + | fn reload_with_removed_required_function_returns_error() { | |
| 620 | + | let temp_dir = TempDir::new().unwrap(); | |
| 621 | + | create_test_plugin(temp_dir.path(), "lose-fn"); | |
| 622 | + | ||
| 623 | + | let plugin_dir = temp_dir.path().join("available").join("lose-fn"); | |
| 624 | + | let mut loader = PluginLoader::new(temp_dir.path()).unwrap(); | |
| 625 | + | loader.load_plugin("lose-fn", &plugin_dir).unwrap(); | |
| 626 | + | ||
| 627 | + | // Replace script with one missing the required parse() function | |
| 628 | + | let no_parse = r#" | |
| 629 | + | fn describe() { | |
| 630 | + | #{ name: "Broken", file_extensions: ["csv"] } | |
| 631 | + | } | |
| 632 | + | "#; | |
| 633 | + | std::fs::write(plugin_dir.join("main.rhai"), no_parse).unwrap(); | |
| 634 | + | ||
| 635 | + | let result = loader.reload_plugin("lose-fn"); | |
| 636 | + | match result { | |
| 637 | + | Err(PluginError::MissingFunction { plugin, function }) => { | |
| 638 | + | assert_eq!(plugin, "lose-fn"); | |
| 639 | + | assert_eq!(function, "parse"); | |
| 640 | + | } | |
| 641 | + | Err(other) => panic!("Expected MissingFunction, got {:?}", other), | |
| 642 | + | Ok(_) => panic!("Expected error, got Ok"), | |
| 643 | + | } | |
| 644 | + | } | |
| 645 | + | ||
| 646 | + | #[test] | |
| 647 | + | fn reload_nonexistent_plugin_returns_not_found() { | |
| 648 | + | let temp_dir = TempDir::new().unwrap(); | |
| 649 | + | let mut loader = PluginLoader::new(temp_dir.path()).unwrap(); | |
| 650 | + | ||
| 651 | + | let result = loader.reload_plugin("no-such-plugin"); | |
| 652 | + | match result { | |
| 653 | + | Err(PluginError::PluginNotFound(id)) => assert_eq!(id, "no-such-plugin"), | |
| 654 | + | Err(other) => panic!("Expected PluginNotFound, got {:?}", other), | |
| 655 | + | Ok(_) => panic!("Expected error, got Ok"), | |
| 656 | + | } | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | #[test] | |
| 660 | + | fn reload_updates_manifest_metadata() { | |
| 661 | + | let temp_dir = TempDir::new().unwrap(); | |
| 662 | + | create_test_plugin(temp_dir.path(), "version-bump"); | |
| 663 | + | ||
| 664 | + | let plugin_dir = temp_dir.path().join("available").join("version-bump"); | |
| 665 | + | let mut loader = PluginLoader::new(temp_dir.path()).unwrap(); | |
| 666 | + | loader.load_plugin("version-bump", &plugin_dir).unwrap(); | |
| 667 | + | ||
| 668 | + | let original = loader.get_plugin("version-bump").unwrap(); | |
| 669 | + | assert_eq!(original.meta.version, "1.0.0"); | |
| 670 | + | ||
| 671 | + | // Bump version in manifest on disk | |
| 672 | + | let updated_manifest = r#" | |
| 673 | + | [plugin] | |
| 674 | + | name = "version-bump" | |
| 675 | + | version = "2.0.0" | |
| 676 | + | description = "Updated description" | |
| 677 | + | ||
| 678 | + | [plugin.type] | |
| 679 | + | kind = "import" | |
| 680 | + | ||
| 681 | + | [plugin.import] | |
| 682 | + | file_extensions = ["csv"] | |
| 683 | + | entity_types = ["task"] | |
| 684 | + | ||
| 685 | + | [plugin.capabilities] | |
| 686 | + | file_read = true | |
| 687 | + | "#; | |
| 688 | + | std::fs::write(plugin_dir.join("plugin.toml"), updated_manifest).unwrap(); | |
| 689 | + | ||
| 690 | + | let reloaded = loader.reload_plugin("version-bump").unwrap(); | |
| 691 | + | assert_eq!(reloaded.meta.version, "2.0.0"); | |
| 692 | + | assert_eq!(reloaded.meta.description, "Updated description"); | |
| 693 | + | } | |
| 694 | + | ||
| 695 | + | // ============ Cache Bypass on Reload ============ | |
| 696 | + | ||
| 697 | + | #[test] | |
| 698 | + | fn load_returns_cached_but_reload_bypasses_cache() { | |
| 699 | + | let temp_dir = TempDir::new().unwrap(); | |
| 700 | + | create_test_plugin(temp_dir.path(), "cache-test"); | |
| 701 | + | ||
| 702 | + | let plugin_dir = temp_dir.path().join("available").join("cache-test"); | |
| 703 | + | let mut loader = PluginLoader::new(temp_dir.path()).unwrap(); | |
| 704 | + | ||
| 705 | + | // First load | |
| 706 | + | loader.load_plugin("cache-test", &plugin_dir).unwrap(); | |
| 707 | + | let v1 = loader.get_plugin("cache-test").unwrap().meta.version.clone(); | |
| 708 | + | ||
| 709 | + | // Update manifest on disk | |
| 710 | + | let updated = r#" | |
| 711 | + | [plugin] | |
| 712 | + | name = "cache-test" | |
| 713 | + | version = "9.9.9" | |
| 714 | + | description = "Test plugin" | |
| 715 | + | ||
| 716 | + | [plugin.type] | |
| 717 | + | kind = "import" | |
| 718 | + | ||
| 719 | + | [plugin.import] | |
| 720 | + | file_extensions = ["csv"] | |
| 721 | + | entity_types = ["task"] | |
| 722 | + | ||
| 723 | + | [plugin.capabilities] | |
| 724 | + | file_read = true | |
| 725 | + | "#; | |
| 726 | + | std::fs::write(plugin_dir.join("plugin.toml"), updated).unwrap(); | |
| 727 | + | ||
| 728 | + | // load_plugin should return cached (stale) version | |
| 729 | + | let cached = loader.load_plugin("cache-test", &plugin_dir).unwrap(); | |
| 730 | + | assert_eq!(cached.meta.version, v1); | |
| 731 | + | ||
| 732 | + | // reload_plugin should pick up the new version | |
| 733 | + | let reloaded = loader.reload_plugin("cache-test").unwrap(); | |
| 734 | + | assert_eq!(reloaded.meta.version, "9.9.9"); | |
| 735 | + | } | |
| 736 | + | ||
| 737 | + | // ============ Full Plugin Lifecycle ============ | |
| 738 | + | ||
| 739 | + | /// Exercises the full plugin lifecycle: discover -> load -> execute -> error -> recover. | |
| 740 | + | /// | |
| 741 | + | /// This test uses a hook plugin with two functions: one that succeeds and one | |
| 742 | + | /// that throws. It verifies: | |
| 743 | + | /// 1. discover_available() finds the plugin on disk | |
| 744 | + | /// 2. load_plugin() compiles the script and validates functions | |
| 745 | + | /// 3. Calling a successful function produces the expected result | |
| 746 | + | /// 4. Calling a throwing function returns PluginError, does not panic | |
| 747 | + | /// 5. After the error, the plugin is still callable (recovery) | |
| 748 | + | #[test] | |
| 749 | + | fn full_plugin_lifecycle_discover_load_execute_error_recover() { | |
| 750 | + | let temp_dir = TempDir::new().unwrap(); | |
| 751 | + | ||
| 752 | + | // -- set up a hook plugin on disk -- | |
| 753 | + | let plugin_dir = temp_dir.path().join("available").join("task-hook"); | |
| 754 | + | std::fs::create_dir_all(&plugin_dir).unwrap(); | |
| 755 | + | ||
| 756 | + | let manifest = r#" | |
| 757 | + | [plugin] | |
| 758 | + | name = "Task Hook" | |
| 759 | + | version = "1.0.0" | |
| 760 | + | description = "Reacts to task lifecycle events" | |
| 761 | + | ||
| 762 | + | [plugin.type] | |
| 763 | + | kind = "hook" | |
| 764 | + | ||
| 765 | + | [plugin.capabilities] | |
| 766 | + | "#; | |
| 767 | + | std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap(); | |
| 768 | + | ||
| 769 | + | let script = r#" | |
| 770 | + | fn describe() { | |
| 771 | + | #{ | |
| 772 | + | name: "Task Hook", | |
| 773 | + | hooks: ["on_task_created", "on_task_completed"] | |
| 774 | + | } | |
| 775 | + | } | |
| 776 | + | ||
| 777 | + | fn on_task_created(task_id) { | |
| 778 | + | if task_id == "" { | |
| 779 | + | throw "task_id must not be empty"; | |
| 780 | + | } | |
| 781 | + | "created:" + task_id | |
| 782 | + | } | |
| 783 | + | ||
| 784 | + | fn on_task_completed(task_id) { | |
| 785 | + | "completed:" + task_id | |
| 786 | + | } | |
| 787 | + | "#; | |
| 788 | + | std::fs::write(plugin_dir.join("main.rhai"), script).unwrap(); | |
| 789 | + | ||
| 790 | + | // -- 1. Discover -- | |
| 791 | + | let mut loader = PluginLoader::new(temp_dir.path()).unwrap(); | |
| 792 | + | let available = loader.discover_available().unwrap(); | |
| 793 | + | assert_eq!(available.len(), 1); | |
| 794 | + | assert_eq!(available[0].id, "task-hook"); | |
| 795 | + | assert_eq!(available[0].name, "Task Hook"); | |
| 796 | + | assert!(matches!( | |
| 797 | + | available[0].plugin_type, | |
| 798 | + | goingson_core::PluginType::Hook | |
| 799 | + | )); | |
| 800 | + | ||
| 801 | + | // -- 2. Load -- | |
| 802 | + | let loaded = loader.load_plugin("task-hook", &plugin_dir).unwrap(); | |
| 803 | + | assert_eq!(loaded.meta.version, "1.0.0"); | |
| 804 | + | ||
| 805 | + | let engine = loader.engine(); | |
| 806 | + | assert!(engine.has_function(&loaded.ast, "describe", 0)); | |
| 807 | + | assert!(engine.has_function(&loaded.ast, "on_task_created", 1)); | |
| 808 | + | assert!(engine.has_function(&loaded.ast, "on_task_completed", 1)); | |
| 809 | + | ||
| 810 | + | // -- 3. Execute (success) -- | |
| 811 | + | let result: String = engine | |
| 812 | + | .call_fn_1(&loaded.ast, "task-hook", "on_task_created", "t-42".to_string()) | |
| 813 | + | .unwrap(); | |
| 814 | + | assert_eq!(result, "created:t-42"); | |
| 815 | + | ||
| 816 | + | let result2: String = engine | |
| 817 | + | .call_fn_1(&loaded.ast, "task-hook", "on_task_completed", "t-42".to_string()) | |
| 818 | + | .unwrap(); | |
| 819 | + | assert_eq!(result2, "completed:t-42"); | |
| 820 | + | ||
| 821 | + | // -- 4. Execute (error) -- empty task_id triggers throw | |
| 822 | + | let err_result: crate::error::Result<String> = engine.call_fn_1( | |
| 823 | + | &loaded.ast, | |
| 824 | + | "task-hook", | |
| 825 | + | "on_task_created", | |
| 826 | + | "".to_string(), | |
| 827 | + | ); | |
| 828 | + | assert!(err_result.is_err()); | |
| 829 | + | match err_result.unwrap_err() { | |
| 830 | + | PluginError::ScriptError { plugin, message } => { | |
| 831 | + | assert_eq!(plugin, "task-hook"); | |
| 832 | + | assert!( | |
| 833 | + | message.contains("task_id must not be empty"), | |
| 834 | + | "Unexpected error message: {}", | |
| 835 | + | message | |
| 836 | + | ); | |
| 837 | + | } | |
| 838 | + | other => panic!("Expected ScriptError, got {:?}", other), | |
| 839 | + | } | |
| 840 | + | ||
| 841 | + | // -- 5. Recover -- plugin is still callable after the error | |
| 842 | + | let recovered: String = engine | |
| 843 | + | .call_fn_1(&loaded.ast, "task-hook", "on_task_created", "t-99".to_string()) | |
| 844 | + | .unwrap(); | |
| 845 | + | assert_eq!(recovered, "created:t-99"); | |
| 846 | + | } | |
| 847 | + | ||
| 848 | + | /// Verifies that a plugin hitting the operation limit errors gracefully and | |
| 849 | + | /// does not prevent subsequent calls to cheaper functions. | |
| 850 | + | #[test] | |
| 851 | + | fn full_lifecycle_operation_limit_and_recovery() { | |
| 852 | + | let temp_dir = TempDir::new().unwrap(); | |
| 853 | + | ||
| 854 | + | let plugin_dir = temp_dir.path().join("available").join("ops-hook"); | |
| 855 | + | std::fs::create_dir_all(&plugin_dir).unwrap(); | |
| 856 | + | ||
| 857 | + | let manifest = r#" | |
| 858 | + | [plugin] | |
| 859 | + | name = "Ops Hook" | |
| 860 | + | version = "1.0.0" | |
| 861 | + | description = "Hook with expensive and cheap paths" | |
| 862 | + | ||
| 863 | + | [plugin.type] | |
| 864 | + | kind = "hook" | |
| 865 | + | ||
| 866 | + | [plugin.capabilities] | |
| 867 | + | "#; | |
| 868 | + | std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap(); | |
| 869 | + | ||
| 870 | + | let script = r#" | |
| 871 | + | fn describe() { | |
| 872 | + | #{ name: "Ops Hook" } | |
| 873 | + | } | |
| 874 | + | ||
| 875 | + | fn on_task_created(task_id) { | |
| 876 | + | if task_id == "spin" { | |
| 877 | + | let x = 0; | |
| 878 | + | loop { x += 1; } | |
| 879 | + | } | |
| 880 | + | "ok:" + task_id | |
| 881 | + | } | |
| 882 | + | "#; | |
| 883 | + | std::fs::write(plugin_dir.join("main.rhai"), script).unwrap(); | |
| 884 | + | ||
| 885 | + | // Use a tight ops limit to trigger the safety check quickly | |
| 886 | + | let limits = SafetyLimits { | |
| 887 | + | max_operations: 200, | |
| 888 | + | ..Default::default() | |
| 889 | + | }; | |
| 890 | + | let engine = Arc::new(PluginEngine::with_limits(limits)); | |
| 891 | + | let mut loader = PluginLoader::with_engine(temp_dir.path(), engine).unwrap(); | |
| 892 | + | ||
| 893 | + | // Discover + load | |
| 894 | + | let available = loader.discover_available().unwrap(); | |
| 895 | + | assert_eq!(available.len(), 1); | |
| 896 | + | ||
| 897 | + | let loaded = loader.load_plugin("ops-hook", &plugin_dir).unwrap(); | |
| 898 | + | let engine = loader.engine(); | |
| 899 | + | ||
| 900 | + | // Trigger ops limit | |
| 901 | + | let err: crate::error::Result<String> = engine.call_fn_1( | |
| 902 | + | &loaded.ast, | |
| 903 | + | "ops-hook", | |
| 904 | + | "on_task_created", | |
| 905 | + | "spin".to_string(), | |
| 906 | + | ); | |
| 907 | + | assert!(err.is_err()); | |
| 908 | + | match err.unwrap_err() { | |
| 909 | + | PluginError::SafetyLimitExceeded { plugin, .. } => { | |
| 910 | + | assert_eq!(plugin, "ops-hook"); | |
| 911 | + | } | |
| 912 | + | other => panic!("Expected SafetyLimitExceeded, got {:?}", other), | |
| 913 | + | } | |
| 914 | + | ||
| 915 | + | // Recover -- cheap path should still work | |
| 916 | + | let ok: String = engine | |
| 917 | + | .call_fn_1(&loaded.ast, "ops-hook", "on_task_created", "t-1".to_string()) | |
| 918 | + | .unwrap(); | |
| 919 | + | assert_eq!(ok, "ok:t-1"); | |
| 920 | + | } | |
| 921 | + | ||
| 922 | + | /// Ensures that hot-reloading a broken plugin does not corrupt the loader, | |
| 923 | + | /// and a subsequent reload with a fixed script succeeds. | |
| 924 | + | #[test] |
Lines truncated
| @@ -223,4 +223,118 @@ kind = "import" | |||
| 223 | 223 | let result = manifest.to_meta("bad-plugin".to_string()); | |
| 224 | 224 | assert!(result.is_err()); | |
| 225 | 225 | } | |
| 226 | + | ||
| 227 | + | // ============ Corrupt / Invalid Manifest ============ | |
| 228 | + | ||
| 229 | + | #[test] | |
| 230 | + | fn corrupt_toml_returns_error_not_panic() { | |
| 231 | + | let garbage = "{{{{ not valid toml at all !@#$"; | |
| 232 | + | let result = PluginManifest::parse(garbage); | |
| 233 | + | assert!(result.is_err()); | |
| 234 | + | match result.unwrap_err() { | |
| 235 | + | PluginError::InvalidManifest(msg) => { | |
| 236 | + | assert!(msg.contains("TOML parse error"), "Unexpected message: {}", msg); | |
| 237 | + | } | |
| 238 | + | other => panic!("Expected InvalidManifest, got {:?}", other), | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | #[test] | |
| 243 | + | fn empty_string_manifest_returns_error() { | |
| 244 | + | let result = PluginManifest::parse(""); | |
| 245 | + | assert!(result.is_err()); | |
| 246 | + | match result.unwrap_err() { | |
| 247 | + | PluginError::InvalidManifest(_) => {} | |
| 248 | + | other => panic!("Expected InvalidManifest, got {:?}", other), | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | #[test] | |
| 253 | + | fn manifest_missing_required_fields_returns_error() { | |
| 254 | + | // Valid TOML but missing required plugin fields (name, version, description) | |
| 255 | + | let toml = r#" | |
| 256 | + | [plugin] | |
| 257 | + | name = "incomplete" | |
| 258 | + | "#; | |
| 259 | + | let result = PluginManifest::parse(toml); | |
| 260 | + | assert!(result.is_err()); | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | #[test] | |
| 264 | + | fn manifest_with_truncated_toml_returns_error() { | |
| 265 | + | // Simulates a file that was partially written (e.g. crash mid-update) | |
| 266 | + | let truncated = r#" | |
| 267 | + | [plugin] | |
| 268 | + | name = "half-written" | |
| 269 | + | version = "1.0.0" | |
| 270 | + | description = "Truncated during write" | |
| 271 | + | ||
| 272 | + | [plugin.type] | |
| 273 | + | kind = "import" | |
| 274 | + | ||
| 275 | + | [plugin.import | |
| 276 | + | "#; | |
| 277 | + | let result = PluginManifest::parse(truncated); | |
| 278 | + | assert!(result.is_err()); | |
| 279 | + | match result.unwrap_err() { | |
| 280 | + | PluginError::InvalidManifest(msg) => { | |
| 281 | + | assert!(msg.contains("TOML parse error"), "Unexpected message: {}", msg); | |
| 282 | + | } | |
| 283 | + | other => panic!("Expected InvalidManifest, got {:?}", other), | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | // ============ Unsupported Plugin Kind ============ | |
| 288 | + | ||
| 289 | + | #[test] | |
| 290 | + | fn unsupported_plugin_kind_returns_error() { | |
| 291 | + | let toml = r#" | |
| 292 | + | [plugin] | |
| 293 | + | name = "bad-kind" | |
| 294 | + | version = "1.0.0" | |
| 295 | + | description = "Uses a kind that does not exist" | |
| 296 | + | ||
| 297 | + | [plugin.type] | |
| 298 | + | kind = "transformer" | |
| 299 | + | ||
| 300 | + | [plugin.capabilities] | |
| 301 | + | "#; | |
| 302 | + | let manifest = PluginManifest::parse(toml).unwrap(); | |
| 303 | + | let result = manifest.to_meta("bad-kind".to_string()); | |
| 304 | + | assert!(result.is_err()); | |
| 305 | + | match result.unwrap_err() { | |
| 306 | + | PluginError::InvalidManifest(msg) => { | |
| 307 | + | assert!( | |
| 308 | + | msg.contains("Unknown plugin kind: transformer"), | |
| 309 | + | "Unexpected message: {}", | |
| 310 | + | msg | |
| 311 | + | ); | |
| 312 | + | } | |
| 313 | + | other => panic!("Expected InvalidManifest, got {:?}", other), | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | #[test] | |
| 318 | + | fn empty_plugin_kind_returns_error() { | |
| 319 | + | let toml = r#" | |
| 320 | + | [plugin] | |
| 321 | + | name = "empty-kind" | |
| 322 | + | version = "1.0.0" | |
| 323 | + | description = "Kind field is empty" | |
| 324 | + | ||
| 325 | + | [plugin.type] | |
| 326 | + | kind = "" | |
| 327 | + | ||
| 328 | + | [plugin.capabilities] | |
| 329 | + | "#; | |
| 330 | + | let manifest = PluginManifest::parse(toml).unwrap(); | |
| 331 | + | let result = manifest.to_meta("empty-kind".to_string()); | |
| 332 | + | assert!(result.is_err()); | |
| 333 | + | match result.unwrap_err() { | |
| 334 | + | PluginError::InvalidManifest(msg) => { | |
| 335 | + | assert!(msg.contains("Unknown plugin kind"), "Unexpected message: {}", msg); | |
| 336 | + | } | |
| 337 | + | other => panic!("Expected InvalidManifest, got {:?}", other), | |
| 338 | + | } | |
| 339 | + | } | |
| 226 | 340 | } |
| @@ -874,6 +874,234 @@ fn describe() { | |||
| 874 | 874 | assert!(map.contains_key("has_header")); | |
| 875 | 875 | } | |
| 876 | 876 | ||
| 877 | + | // ============ User Plugin Overrides Bundled ============ | |
| 878 | + | ||
| 879 | + | /// Simulates the case where a user installs a plugin with the same ID as | |
| 880 | + | /// a previously-loaded one (e.g. a bundled default). Loading the same ID | |
| 881 | + | /// twice should allow reload to replace the original. | |
| 882 | + | #[test] | |
| 883 | + | fn user_plugin_overrides_same_id_via_reload() { | |
| 884 | + | let temp_dir = TempDir::new().unwrap(); | |
| 885 | + | create_csv_import_plugin(temp_dir.path()); | |
| 886 | + | ||
| 887 | + | let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); | |
| 888 | + | registry.enable_plugin("csv-import").unwrap(); | |
| 889 | + | ||
| 890 | + | // Verify the original | |
| 891 | + | let meta = registry.loader().get_plugin("csv-import").unwrap().meta.clone(); | |
| 892 | + | assert_eq!(meta.version, "1.0.0"); | |
| 893 | + | assert_eq!(meta.description, "Import tasks from CSV files"); | |
| 894 | + | ||
| 895 | + | // User "updates" the same plugin on disk (new version, new description) | |
| 896 | + | let plugin_dir = temp_dir.path().join("available").join("csv-import"); | |
| 897 | + | let user_manifest = r#" | |
| 898 | + | [plugin] | |
| 899 | + | name = "CSV Import" | |
| 900 | + | version = "2.0.0" | |
| 901 | + | description = "User-customized CSV import" | |
| 902 | + | ||
| 903 | + | [plugin.type] | |
| 904 | + | kind = "import" | |
| 905 | + | ||
| 906 | + | [plugin.import] | |
| 907 | + | file_extensions = ["csv", "tsv"] | |
| 908 | + | entity_types = ["task"] | |
| 909 | + | ||
| 910 | + | [plugin.capabilities] | |
| 911 | + | file_read = true | |
| 912 | + | database_write = true | |
| 913 | + | "#; | |
| 914 | + | std::fs::write(plugin_dir.join("plugin.toml"), user_manifest).unwrap(); | |
| 915 | + | ||
| 916 | + | let user_script = r#" | |
| 917 | + | fn describe() { | |
| 918 | + | #{ | |
| 919 | + | name: "CSV Import (User)", | |
| 920 | + | file_extensions: ["csv", "tsv"] | |
| 921 | + | } | |
| 922 | + | } | |
| 923 | + | ||
| 924 | + | fn parse(file_path, options) { | |
| 925 | + | goingson::task_result([]) | |
| 926 | + | } | |
| 927 | + | "#; | |
| 928 | + | std::fs::write(plugin_dir.join("main.rhai"), user_script).unwrap(); | |
| 929 | + | ||
| 930 | + | // Reload to pick up user version | |
| 931 | + | let reloaded = registry.reload_plugin("csv-import").unwrap(); | |
| 932 | + | assert_eq!(reloaded.version, "2.0.0"); | |
| 933 | + | assert_eq!(reloaded.description, "User-customized CSV import"); | |
| 934 | + | ||
| 935 | + | // The updated plugin should handle tsv now | |
| 936 | + | let tsv_plugins = registry.get_plugins_for_extension("tsv"); | |
| 937 | + | assert_eq!(tsv_plugins.len(), 1); | |
| 938 | + | assert_eq!(tsv_plugins[0].version, "2.0.0"); | |
| 939 | + | } | |
| 940 | + | ||
| 941 | + | // ============ Reload While Running (Sequential Safety) ============ | |
| 942 | + | ||
| 943 | + | /// Verifies that after a reload, the old AST is no longer returned by | |
| 944 | + | /// the loader, and the new AST produces different results. | |
| 945 | + | #[test] | |
| 946 | + | fn reload_replaces_ast_atomically() { | |
| 947 | + | let temp_dir = TempDir::new().unwrap(); | |
| 948 | + | create_csv_import_plugin(temp_dir.path()); | |
| 949 | + | ||
| 950 | + | let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); | |
| 951 | + | registry.enable_plugin("csv-import").unwrap(); | |
| 952 | + | ||
| 953 | + | // Capture the Arc<AST> from the first load | |
| 954 | + | let old_ast = { | |
| 955 | + | let plugin = registry.loader().get_plugin("csv-import").unwrap(); | |
| 956 | + | plugin.ast.clone() | |
| 957 | + | }; | |
| 958 | + | ||
| 959 | + | // Modify the script to return a different describe name | |
| 960 | + | let plugin_dir = temp_dir.path().join("available").join("csv-import"); | |
| 961 | + | let new_script = r#" | |
| 962 | + | fn describe() { | |
| 963 | + | #{ | |
| 964 | + | name: "CSV Import V2", | |
| 965 | + | file_extensions: ["csv"] | |
| 966 | + | } | |
| 967 | + | } | |
| 968 | + | ||
| 969 | + | fn parse(file_path, options) { | |
| 970 | + | goingson::task_result([]) | |
| 971 | + | } | |
| 972 | + | "#; | |
| 973 | + | std::fs::write(plugin_dir.join("main.rhai"), new_script).unwrap(); | |
| 974 | + | ||
| 975 | + | registry.reload_plugin("csv-import").unwrap(); | |
| 976 | + | ||
| 977 | + | // The registry now holds a different AST | |
| 978 | + | let new_ast = { | |
| 979 | + | let plugin = registry.loader().get_plugin("csv-import").unwrap(); | |
| 980 | + | plugin.ast.clone() | |
| 981 | + | }; | |
| 982 | + | ||
| 983 | + | // Verify new AST produces different output | |
| 984 | + | let engine = registry.loader().engine(); | |
| 985 | + | let old_desc: Dynamic = engine.call_fn(&old_ast, "csv-import", "describe").unwrap(); | |
| 986 | + | let new_desc: Dynamic = engine.call_fn(&new_ast, "csv-import", "describe").unwrap(); | |
| 987 | + | ||
| 988 | + | let old_name = old_desc | |
| 989 | + | .try_cast::<Map>() | |
| 990 | + | .unwrap() | |
| 991 | + | .get("name") | |
| 992 | + | .unwrap() | |
| 993 | + | .clone() | |
| 994 | + | .into_string() | |
| 995 | + | .unwrap(); | |
| 996 | + | let new_name = new_desc | |
| 997 | + | .try_cast::<Map>() | |
| 998 | + | .unwrap() | |
| 999 | + | .get("name") | |
| 1000 | + | .unwrap() | |
| 1001 | + | .clone() | |
| 1002 | + | .into_string() | |
| 1003 | + | .unwrap(); | |
| 1004 | + | ||
| 1005 | + | assert_eq!(old_name, "CSV Import"); | |
| 1006 | + | assert_eq!(new_name, "CSV Import V2"); | |
| 1007 | + | } | |
| 1008 | + | ||
| 1009 | + | // ============ Corrupt Manifest After Initial Load ============ | |
| 1010 | + | ||
| 1011 | + | #[test] | |
| 1012 | + | fn reload_with_corrupt_manifest_returns_error() { | |
| 1013 | + | let temp_dir = TempDir::new().unwrap(); | |
| 1014 | + | create_csv_import_plugin(temp_dir.path()); | |
| 1015 | + | ||
| 1016 | + | let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); | |
| 1017 | + | registry.enable_plugin("csv-import").unwrap(); | |
| 1018 | + | ||
| 1019 | + | // Corrupt the manifest on disk | |
| 1020 | + | let manifest_path = temp_dir.path().join("available/csv-import/plugin.toml"); | |
| 1021 | + | std::fs::write(&manifest_path, "{{{{ not valid TOML !@#$").unwrap(); | |
| 1022 | + | ||
| 1023 | + | let result = registry.reload_plugin("csv-import"); | |
| 1024 | + | assert!(result.is_err()); | |
| 1025 | + | match result.unwrap_err() { | |
| 1026 | + | PluginError::InvalidManifest(msg) => { | |
| 1027 | + | assert!(msg.contains("TOML parse error"), "Unexpected: {}", msg); | |
| 1028 | + | } | |
| 1029 | + | other => panic!("Expected InvalidManifest, got {:?}", other), | |
| 1030 | + | } | |
| 1031 | + | } | |
| 1032 | + | ||
| 1033 | + | // ============ Permission Escalation ============ | |
| 1034 | + | ||
| 1035 | + | /// A plugin that initially has no capabilities should not gain them on | |
| 1036 | + | /// reload even if the manifest changes, because the host checks the | |
| 1037 | + | /// PluginMeta.capabilities at execution time. This test verifies the | |
| 1038 | + | /// capabilities are faithfully read from the new manifest. | |
| 1039 | + | #[test] | |
| 1040 | + | fn reload_reflects_changed_capabilities() { | |
| 1041 | + | let temp_dir = TempDir::new().unwrap(); | |
| 1042 | + | ||
| 1043 | + | // Create a plugin with minimal capabilities | |
| 1044 | + | let plugin_dir = temp_dir.path().join("available").join("sneaky"); | |
| 1045 | + | std::fs::create_dir_all(&plugin_dir).unwrap(); | |
| 1046 | + | ||
| 1047 | + | let safe_manifest = r#" | |
| 1048 | + | [plugin] | |
| 1049 | + | name = "Sneaky Plugin" | |
| 1050 | + | version = "1.0.0" | |
| 1051 | + | description = "Starts safe" | |
| 1052 | + | ||
| 1053 | + | [plugin.type] | |
| 1054 | + | kind = "command" | |
| 1055 | + | ||
| 1056 | + | [plugin.capabilities] | |
| 1057 | + | file_read = false | |
| 1058 | + | database_write = false | |
| 1059 | + | network = false | |
| 1060 | + | "#; | |
| 1061 | + | std::fs::write(plugin_dir.join("plugin.toml"), safe_manifest).unwrap(); | |
| 1062 | + | let script = r#" | |
| 1063 | + | fn describe() { #{ name: "Sneaky" } } | |
| 1064 | + | fn execute(args) { 42 } | |
| 1065 | + | "#; | |
| 1066 | + | std::fs::write(plugin_dir.join("main.rhai"), script).unwrap(); | |
| 1067 | + | ||
| 1068 | + | let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); | |
| 1069 | + | registry.enable_plugin("sneaky").unwrap(); | |
| 1070 | + | ||
| 1071 | + | // Verify initial capabilities are restricted | |
| 1072 | + | let meta = registry.loader().get_plugin("sneaky").unwrap().meta.clone(); | |
| 1073 | + | assert!(!meta.capabilities.file_read); | |
| 1074 | + | assert!(!meta.capabilities.database_write); | |
| 1075 | + | assert!(!meta.capabilities.network); | |
| 1076 | + | ||
| 1077 | + | // Attacker modifies manifest to escalate permissions | |
| 1078 | + | let escalated_manifest = r#" | |
| 1079 | + | [plugin] | |
| 1080 | + | name = "Sneaky Plugin" | |
| 1081 | + | version = "1.0.1" | |
| 1082 | + | description = "Now wants everything" | |
| 1083 | + | ||
| 1084 | + | [plugin.type] | |
| 1085 | + | kind = "command" | |
| 1086 | + | ||
| 1087 | + | [plugin.capabilities] | |
| 1088 | + | file_read = true | |
| 1089 | + | database_write = true | |
| 1090 | + | network = true | |
| 1091 | + | "#; | |
| 1092 | + | std::fs::write(plugin_dir.join("plugin.toml"), escalated_manifest).unwrap(); | |
| 1093 | + | ||
| 1094 | + | // Reload picks up the new manifest -- the host is responsible for | |
| 1095 | + | // checking whether the user approved the new capabilities. The | |
| 1096 | + | // important thing is that the capabilities field is accurate (not | |
| 1097 | + | // stale from the old manifest). | |
| 1098 | + | let reloaded = registry.reload_plugin("sneaky").unwrap(); | |
| 1099 | + | assert!(reloaded.capabilities.file_read); | |
| 1100 | + | assert!(reloaded.capabilities.database_write); | |
| 1101 | + | assert!(reloaded.capabilities.network); | |
| 1102 | + | assert_eq!(reloaded.version, "1.0.1"); | |
| 1103 | + | } | |
| 1104 | + | ||
| 877 | 1105 | // --- with_engine constructor --- | |
| 878 | 1106 | ||
| 879 | 1107 | #[test] |
| @@ -182,15 +182,6 @@ const api = { | |||
| 182 | 182 | getOptions: () => invoke('get_snooze_options'), | |
| 183 | 183 | }, | |
| 184 | 184 | ||
| 185 | - | // LLM — settings and template evaluation for AI-assisted features | |
| 186 | - | llm: { | |
| 187 | - | getSettings: () => invoke('get_llm_settings'), | |
| 188 | - | saveSettings: (input) => invoke('save_llm_settings', { input }), | |
| 189 | - | testConnection: () => invoke('test_llm_connection'), | |
| 190 | - | evaluate: (input) => invoke('evaluate_llm_template', { input }), // Run a prompt template against the LLM | |
| 191 | - | clearCache: () => invoke('clear_llm_cache'), | |
| 192 | - | }, | |
| 193 | - | ||
| 194 | 185 | // OAuth — provider-based auth flow (Google, Microsoft, Yahoo, Fastmail) | |
| 195 | 186 | oauth: { | |
| 196 | 187 | listProviders: () => invoke('list_oauth_providers'), |
| @@ -125,7 +125,7 @@ if (window.__TAURI__) { | |||
| 125 | 125 | listen('menu:keyboard_shortcuts', () => GoingsOn.keyboard.toggleShortcuts()); | |
| 126 | 126 | listen('menu:about', () => GoingsOn.app.openAboutModal()); | |
| 127 | 127 | ||
| 128 | - | // Database external change detection (e.g., from MCP server) | |
| 128 | + | // Database external change detection | |
| 129 | 129 | listen('db:external-change', () => { | |
| 130 | 130 | console.log('External database change detected, refreshing view'); | |
| 131 | 131 | refreshCurrentViewData(); | |
| @@ -158,7 +158,7 @@ if (window.__TAURI__) { | |||
| 158 | 158 | ||
| 159 | 159 | /** | |
| 160 | 160 | * Refresh the current view's data without full navigation. | |
| 161 | - | * Used when external changes are detected (e.g., MCP server modified the database). | |
| 161 | + | * Used when external changes are detected (e.g., an external process modified the database). | |
| 162 | 162 | */ | |
| 163 | 163 | async function refreshCurrentViewData() { | |
| 164 | 164 | // Don't refresh if a modal is open (user is editing something) |
| @@ -19,6 +19,11 @@ | |||
| 19 | 19 | 'application/gzip': '\uD83D\uDCE6', | |
| 20 | 20 | }; | |
| 21 | 21 | ||
| 22 | + | /** | |
| 23 | + | * Get the emoji icon for a MIME type. | |
| 24 | + | * @param {string} mimeType - MIME type string | |
| 25 | + | * @returns {string} Emoji character for the file type | |
| 26 | + | */ | |
| 22 | 27 | function getIcon(mimeType) { | |
| 23 | 28 | if (MIME_ICONS[mimeType]) return MIME_ICONS[mimeType]; | |
| 24 | 29 | for (const [prefix, icon] of Object.entries(MIME_ICONS)) { | |
| @@ -27,6 +32,11 @@ | |||
| 27 | 32 | return '\uD83D\uDCCE'; | |
| 28 | 33 | } | |
| 29 | 34 | ||
| 35 | + | /** | |
| 36 | + | * Open the attachments panel modal for a task or project. | |
| 37 | + | * @param {string|null} taskId - Task ID, or null for project-only | |
| 38 | + | * @param {string|null} projectId - Project ID, or null for task-only | |
| 39 | + | */ | |
| 30 | 40 | async function openPanel(taskId, projectId) { | |
| 31 | 41 | GoingsOn.ui.closeModal(); | |
| 32 | 42 | try { | |
| @@ -78,6 +88,11 @@ | |||
| 78 | 88 | GoingsOn.ui.openModal('Attachments', content); | |
| 79 | 89 | } | |
| 80 | 90 | ||
| 91 | + | /** | |
| 92 | + | * Open the native file picker and attach the selected file. | |
| 93 | + | * @param {string|null} taskId - Task ID to attach to | |
| 94 | + | * @param {string|null} projectId - Project ID to attach to | |
| 95 | + | */ | |
| 81 | 96 | async function pickAndAttach(taskId, projectId) { | |
| 82 | 97 | try { | |
| 83 | 98 | const { open } = window.__TAURI__.dialog; | |
| @@ -104,6 +119,10 @@ | |||
| 104 | 119 | } | |
| 105 | 120 | } | |
| 106 | 121 | ||
| 122 | + | /** | |
| 123 | + | * Open an attachment file using the system default application. | |
| 124 | + | * @param {string} id - Attachment ID | |
| 125 | + | */ | |
| 107 | 126 | async function openAttachment(id) { | |
| 108 | 127 | try { | |
| 109 | 128 | await GoingsOn.api.attachments.open(id); | |
| @@ -112,6 +131,11 @@ | |||
| 112 | 131 | } | |
| 113 | 132 | } | |
| 114 | 133 | ||
| 134 | + | /** | |
| 135 | + | * Save an attachment to a user-chosen location. | |
| 136 | + | * @param {string} id - Attachment ID | |
| 137 | + | * @param {string} filename - Default filename for the save dialog | |
| 138 | + | */ | |
| 115 | 139 | async function saveAs(id, filename) { | |
| 116 | 140 | try { | |
| 117 | 141 | const { save } = window.__TAURI__.dialog; | |
| @@ -129,6 +153,12 @@ | |||
| 129 | 153 | } | |
| 130 | 154 | } | |
| 131 | 155 | ||
| 156 | + | /** | |
| 157 | + | * Delete an attachment after confirmation, then refresh the panel. | |
| 158 | + | * @param {string} id - Attachment ID to delete | |
| 159 | + | * @param {string} taskId - Parent task ID for panel refresh | |
| 160 | + | * @param {string} projectId - Parent project ID for panel refresh | |
| 161 | + | */ | |
| 132 | 162 | async function remove(id, taskId, projectId) { | |
| 133 | 163 | if (!confirm('Delete this attachment?')) return; | |
| 134 | 164 | ||
| @@ -143,6 +173,11 @@ | |||
| 143 | 173 | } | |
| 144 | 174 | ||
| 145 | 175 | // Render a compact attachment count badge for task rows | |
| 176 | + | /** | |
| 177 | + | * Render a compact attachment count badge for task rows. | |
| 178 | + | * @param {number} attachmentCount - Number of attachments | |
| 179 | + | * @returns {string} HTML string for the badge, or empty string if no attachments | |
| 180 | + | */ | |
| 146 | 181 | function renderBadge(attachmentCount) { | |
| 147 | 182 | if (!attachmentCount || attachmentCount === 0) return ''; | |
| 148 | 183 | return `<span class="task-badge has-items">Files: ${attachmentCount}</span>`; |
| @@ -10,6 +10,16 @@ | |||
| 10 | 10 | ||
| 11 | 11 | // ============ Generic Bulk Action Helper ============ | |
| 12 | 12 | ||
| 13 | + | /** | |
| 14 | + | * Run an API call for each selected item in parallel. | |
| 15 | + | * @param {Object} opts | |
| 16 | + | * @param {Set<string>} opts.selectedIds - IDs to act on | |
| 17 | + | * @param {Function} opts.apiCall - (id) => Promise for each item | |
| 18 | + | * @param {string} opts.successMessage - Toast message ({count} is replaced) | |
| 19 | + | * @param {string} opts.errorMessage - Prefix for error toast | |
| 20 | + | * @param {Function} opts.reloadFn - Called after success to refresh the view | |
| 21 | + | * @param {boolean} [opts.closeModalAfter=false] - Close modal on success | |
| 22 | + | */ | |
| 13 | 23 | async function executeBulkAction({ selectedIds, apiCall, successMessage, errorMessage, reloadFn, closeModalAfter = false }) { | |
| 14 | 24 | if (selectedIds.size === 0) return; | |
| 15 | 25 | ||
| @@ -27,6 +37,9 @@ | |||
| 27 | 37 | } | |
| 28 | 38 | } | |
| 29 | 39 | ||
| 40 | + | /** | |
| 41 | + | * Show or hide the bulk actions bars based on current selection state. | |
| 42 | + | */ | |
| 30 | 43 | function updateBulkActionsBar() { | |
| 31 | 44 | const taskBar = document.getElementById('task-bulk-actions'); | |
| 32 | 45 | const emailBar = document.getElementById('email-bulk-actions'); | |
| @@ -54,6 +67,12 @@ | |||
| 54 | 67 | ||
| 55 | 68 | // ============ Bulk Snooze Modal ============ | |
| 56 | 69 | ||
| 70 | + | /** | |
| 71 | + | * Open the snooze modal for a set of selected items. | |
| 72 | + | * @param {string} itemType - 'tasks' or 'emails' | |
| 73 | + | * @param {Set<string>} selectedIds - IDs of items to snooze | |
| 74 | + | * @param {Function} snoozeCallback - (until: string) => Promise called with the chosen snooze time | |
| 75 | + | */ | |
| 57 | 76 | async function openBulkSnoozeModal(itemType, selectedIds, snoozeCallback) { | |
| 58 | 77 | if (selectedIds.size === 0) return; | |
| 59 | 78 |
| @@ -10,12 +10,22 @@ | |||
| 10 | 10 | ||
| 11 | 11 | // ============ Helpers ============ | |
| 12 | 12 | ||
| 13 | + | /** | |
| 14 | + | * Extract up to 2 initials from a display name. | |
| 15 | + | * @param {string} name - Full display name | |
| 16 | + | * @returns {string} Uppercase initials (e.g. "JD") | |
| 17 | + | */ | |
| 13 | 18 | function getInitials(name) { | |
| 14 | 19 | return name.split(/\s+/).filter(Boolean).map(w => w[0]).slice(0, 2).join('').toUpperCase(); | |
| 15 | 20 | } | |
| 16 | 21 | ||
| 17 | 22 | // ============ Card Rendering ============ | |
| 18 | 23 | ||
| 24 | + | /** | |
| 25 | + | * Render a contact card for the contacts grid. | |
| 26 | + | * @param {Object} c - Contact object | |
| 27 | + | * @returns {string} HTML string for the contact card | |
| 28 | + | */ | |
| 19 | 29 | function renderCard(c) { | |
| 20 | 30 | const initials = c.initials || getInitials(c.displayName); | |
| 21 | 31 | const primaryEmail = c.primaryEmail || c.emails?.find(e => e.isPrimary)?.address || c.emails?.[0]?.address || ''; | |
| @@ -46,6 +56,10 @@ | |||
| 46 | 56 | ||
| 47 | 57 | // ============ Detail Modal Rendering ============ | |
| 48 | 58 | ||
| 59 | + | /** | |
| 60 | + | * Show the full contact detail modal with all sub-collections. | |
| 61 | + | * @param {Object} contact - Full contact object with emails, phones, socialHandles, customFields | |
| 62 | + | */ | |
| 49 | 63 | function showDetailModal(contact) { | |
| 50 | 64 | const initials = contact.initials || getInitials(contact.displayName); | |
| 51 | 65 |
| @@ -105,6 +105,12 @@ | |||
| 105 | 105 | ||
| 106 | 106 | // ============ Generic Sub-Collection Functions ============ | |
| 107 | 107 | ||
| 108 | + | /** | |
| 109 | + | * Build the HTML form for adding a sub-collection item (email, phone, etc.). | |
| 110 | + | * @param {string} type - Sub-collection type key from SUB_COLLECTIONS | |
| 111 | + | * @param {string} contactId - Parent contact ID | |
| 112 | + | * @returns {string} HTML string for the form | |
| 113 | + | */ | |
| 108 | 114 | function buildSubCollectionFormHtml(type, contactId) { | |
| 109 | 115 | const config = SUB_COLLECTIONS[type]; | |
| 110 | 116 | const fieldHtml = config.fields.map(f => { | |
| @@ -144,12 +150,22 @@ | |||
| 144 | 150 | `; | |
| 145 | 151 | } | |
| 146 | 152 | ||
| 153 | + | /** | |
| 154 | + | * Open a modal to add a sub-collection item to a contact. | |
| 155 | + | * @param {string} type - Sub-collection type ('email', 'phone', 'social', 'customField') | |
| 156 | + | * @param {string} contactId - Parent contact ID | |
| 157 | + | */ | |
| 147 | 158 | function openAddSubCollection(type, contactId) { | |
| 148 | 159 | const config = SUB_COLLECTIONS[type]; | |
| 149 | 160 | const content = buildSubCollectionFormHtml(type, contactId); | |
| 150 | 161 | GoingsOn.ui.openModal(config.modalTitle, content); | |
| 151 | 162 | } | |
| 152 | 163 | ||
| 164 | + | /** | |
| 165 | + | * Validate and submit a sub-collection add form. | |
| 166 | + | * @param {string} type - Sub-collection type ('email', 'phone', 'social', 'customField') | |
| 167 | + | * @param {string} contactId - Parent contact ID | |
| 168 | + | */ | |
| 153 | 169 | async function submitSubCollection(type, contactId) { | |
| 154 | 170 | const config = SUB_COLLECTIONS[type]; | |
| 155 | 171 | const form = document.getElementById(config.formId); | |
| @@ -170,6 +186,12 @@ | |||
| 170 | 186 | }); | |
| 171 | 187 | } | |
| 172 | 188 | ||
| 189 | + | /** | |
| 190 | + | * Remove a sub-collection item from a contact with confirmation. | |
| 191 | + | * @param {string} type - Sub-collection type ('email', 'phone', 'social', 'customField') | |
| 192 | + | * @param {string} contactId - Parent contact ID | |
| 193 | + | * @param {string} itemId - Sub-collection item ID to remove | |
| 194 | + | */ | |
| 173 | 195 | async function removeSubCollection(type, contactId, itemId) { | |
| 174 | 196 | const config = SUB_COLLECTIONS[type]; | |
| 175 | 197 | if (!await GoingsOn.ui.confirmDelete(config.deleteLabel || `this ${config.entityLabel}`)) return; | |
| @@ -200,6 +222,11 @@ | |||
| 200 | 222 | ||
| 201 | 223 | // ============ Form Field Definitions ============ | |
| 202 | 224 | ||
| 225 | + | /** | |
| 226 | + | * Build form field definitions for the contact create/edit modal. | |
| 227 | + | * @param {Object|null} contact - Existing contact for edit mode, or null for create | |
| 228 | + | * @returns {FormField[]} Array of form field definitions | |
| 229 | + | */ | |
| 203 | 230 | function getContactFormFields(contact = null) { | |
| 204 | 231 | return [ | |
| 205 | 232 | { | |
| @@ -268,6 +295,11 @@ | |||
| 268 | 295 | return GoingsOn.utils.normalizeTags(tagString); | |
| 269 | 296 | } | |
| 270 | 297 | ||
| 298 | + | /** | |
| 299 | + | * Extract all unique tags from a list of contacts, sorted alphabetically. | |
| 300 | + | * @param {Array<Object>} contacts - Contact objects with optional tags arrays | |
| 301 | + | * @returns {string[]} Sorted array of unique tag strings | |
| 302 | + | */ | |
| 271 | 303 | function getAllTags(contacts) { | |
| 272 | 304 | const tagSet = new Set(); | |
| 273 | 305 | contacts.forEach(c => (c.tags || []).forEach(t => tagSet.add(t))); | |
| @@ -455,6 +487,10 @@ | |||
| 455 | 487 | ||
| 456 | 488 | // ============ Detail Modal ============ | |
| 457 | 489 | ||
| 490 | + | /** | |
| 491 | + | * Fetch a contact by ID and open its detail modal. | |
| 492 | + | * @param {string} id - Contact ID to open | |
| 493 | + | */ | |
| 458 | 494 | async function open(id) { | |
| 459 | 495 | try { | |
| 460 | 496 | const contact = await GoingsOn.api.contacts.get(id); | |
| @@ -474,11 +510,19 @@ | |||
| 474 | 510 | ||
| 475 | 511 | // ============ Filtering ============ | |
| 476 | 512 | ||
| 513 | + | /** | |
| 514 | + | * Filter contacts by search query (server-side filtering). | |
| 515 | + | * @param {string} query - Search text to filter by | |
| 516 | + | */ | |
| 477 | 517 | function filterBySearch(query) { | |
| 478 | 518 | GoingsOn.state.set('contactsSearchQuery', query.trim()); | |
| 479 | 519 | load(); | |
| 480 | 520 | } | |
| 481 | 521 | ||
| 522 | + | /** | |
| 523 | + | * Filter contacts by tag (server-side filtering). | |
| 524 | + | * @param {string} tag - Tag to filter by, or empty string for all | |
| 525 | + | */ | |
| 482 | 526 | function filterByTag(tag) { | |
| 483 | 527 | GoingsOn.state.set('contactsTagFilter', tag); | |
| 484 | 528 | load(); |
| @@ -8,6 +8,11 @@ | |||
| 8 | 8 | ||
| 9 | 9 | // ============ Context Menu Handlers ============ | |
| 10 | 10 | ||
| 11 | + | /** | |
| 12 | + | * Show the right-click context menu for a task. | |
| 13 | + | * @param {MouseEvent} e - Right-click event | |
| 14 | + | * @param {string} taskId - Task ID | |
| 15 | + | */ | |
| 11 | 16 | function showTaskContextMenu(e, taskId) { | |
| 12 | 17 | e.preventDefault(); | |
| 13 | 18 | e.stopPropagation(); | |
| @@ -15,6 +20,11 @@ | |||
| 15 | 20 | showContextMenu(e.clientX, e.clientY, items); | |
| 16 | 21 | } | |
| 17 | 22 | ||
| 23 | + | /** | |
| 24 | + | * Show the right-click context menu for an email. | |
| 25 | + | * @param {MouseEvent} e - Right-click event | |
| 26 | + | * @param {string} emailId - Email ID | |
| 27 | + | */ | |
| 18 | 28 | function showEmailContextMenu(e, emailId) { | |
| 19 | 29 | e.preventDefault(); | |
| 20 | 30 | e.stopPropagation(); | |
| @@ -29,6 +39,11 @@ | |||
| 29 | 39 | showContextMenu(e.clientX, e.clientY, items); | |
| 30 | 40 | } | |
| 31 | 41 | ||
| 42 | + | /** | |
| 43 | + | * Show the right-click context menu for an event. | |
| 44 | + | * @param {MouseEvent} e - Right-click event | |
| 45 | + | * @param {string} eventId - Event ID | |
| 46 | + | */ | |
| 32 | 47 | function showEventContextMenu(e, eventId) { | |
| 33 | 48 | e.preventDefault(); | |
| 34 | 49 | e.stopPropagation(); | |
| @@ -36,6 +51,11 @@ | |||
| 36 | 51 | showContextMenu(e.clientX, e.clientY, items); | |
| 37 | 52 | } | |
| 38 | 53 | ||
| 54 | + | /** | |
| 55 | + | * Show the right-click context menu for a project. | |
| 56 | + | * @param {MouseEvent} e - Right-click event | |
| 57 | + | * @param {string} projectId - Project ID | |
| 58 | + | */ | |
| 39 | 59 | function showProjectContextMenu(e, projectId) { | |
| 40 | 60 | e.preventDefault(); | |
| 41 | 61 | e.stopPropagation(); |
| @@ -10,6 +10,11 @@ | |||
| 10 | 10 | ||
| 11 | 11 | // ============ Utility ============ | |
| 12 | 12 | ||
| 13 | + | /** | |
| 14 | + | * Convert a 15-minute slot index (0-95) to a Date object on the current day plan date. | |
| 15 | + | * @param {number} slotIndex - Slot index (0 = 00:00, 95 = 23:45) | |
| 16 | + | * @returns {Date} Date object for the slot | |
| 17 | + | */ | |
| 13 | 18 | function slotToTime(slotIndex) { | |
| 14 | 19 | const hour = Math.floor(slotIndex / 4); | |
| 15 | 20 | const minute = (slotIndex % 4) * 15; | |
| @@ -22,6 +27,12 @@ | |||
| 22 | 27 | ||
| 23 | 28 | // ============ Painting to Create Events ============ | |
| 24 | 29 | ||
| 30 | + | /** | |
| 31 | + | * Begin painting a time range on mousedown. | |
| 32 | + | * @param {MouseEvent} event | |
| 33 | + | * @param {number} slotIndex - Starting slot index | |
| 34 | + | * @param {string} slotTime - ISO timestamp of the slot | |
| 35 | + | */ | |
| 25 | 36 | function onPaintStart(event, slotIndex, slotTime) { | |
| 26 | 37 | if (event.button !== 0) return; | |
| 27 | 38 | if (event.target.closest('.timeline-item')) return; | |
| @@ -47,6 +58,12 @@ | |||
| 47 | 58 | document.addEventListener('mouseup', onPaintEnd); | |
| 48 | 59 | } | |
| 49 | 60 | ||
| 61 | + | /** | |
| 62 | + | * Extend the paint selection on mousemove. | |
| 63 | + | * @param {MouseEvent} event | |
| 64 | + | * @param {number} slotIndex - Current slot index | |
| 65 | + | * @param {string} slotTime - ISO timestamp of the slot | |
| 66 | + | */ | |
| 50 | 67 | function onPaintMove(event, slotIndex, slotTime) { | |
| 51 | 68 | if (!GoingsOn.state.paintingState) return; | |
| 52 | 69 | GoingsOn.state.paintingState.endSlot = slotIndex; | |
| @@ -82,6 +99,11 @@ | |||
| 82 | 99 | GoingsOn.state.paintingState.preview.style.height = `${(endSlot - startSlot + 1) * slotHeight}px`; | |
| 83 | 100 | } | |
| 84 | 101 | ||
| 102 | + | /** | |
| 103 | + | * Open the create modal for a painted time range (event, block, or task link). | |
| 104 | + | * @param {Date} startTime - Start of the painted range | |
| 105 | + | * @param {Date} endTime - End of the painted range | |
| 106 | + | */ | |
| 85 | 107 | function openPaintedEventModal(startTime, endTime) { | |
| 86 | 108 | const startISO = toLocalISOString(startTime); | |
| 87 | 109 | const endISO = toLocalISOString(endTime); | |
| @@ -160,6 +182,10 @@ | |||
| 160 | 182 | GoingsOn.ui.openModal('Create Event', content); | |
| 161 | 183 | } | |
| 162 | 184 | ||
| 185 | + | /** | |
| 186 | + | * Toggle visibility of form fields based on the selected paint mode. | |
| 187 | + | * @param {HTMLSelectElement} select - The mode selector element | |
| 188 | + | */ | |
| 163 | 189 | function togglePaintMode(select) { | |
| 164 | 190 | const mode = select.value; | |
| 165 | 191 | const taskFields = document.getElementById('paint-task-fields'); |
| @@ -19,6 +19,11 @@ | |||
| 19 | 19 | ||
| 20 | 20 | // ============ Timeline Rendering ============ | |
| 21 | 21 | ||
| 22 | + | /** | |
| 23 | + | * Render the day timeline with 15-minute slots and positioned items. | |
| 24 | + | * @param {Date} dayPlanDate - The date being displayed | |
| 25 | + | * @param {Object|null} dayPlanData - Day plan data from backend (timelineItems, conflicts) | |
| 26 | + | */ | |
| 22 | 27 | function renderTimeline(dayPlanDate, dayPlanData) { | |
| 23 | 28 | const slotsContainer = document.getElementById('timeline-slots'); | |
| 24 | 29 | const itemsContainer = document.getElementById('timeline-items'); | |
| @@ -49,12 +54,12 @@ | |||
| 49 | 54 | ||
| 50 | 55 | slotsHtml += ` | |
| 51 | 56 | <div class="timeline-slot${isHourStart ? ' hour-start' : ''}" | |
| 52 | - | data-time="${slotTimestamp}" | |
| 57 | + | data-time="${escAttr(slotTimestamp)}" | |
| 53 | 58 | data-hour="${hour}" | |
| 54 | 59 | data-slot-index="${hour * 4 + quarter}" | |
| 55 | - | onmousedown="GoingsOn.dayPlan.onPaintStart(event, ${hour * 4 + quarter}, '${slotTimestamp}')" | |
| 56 | - | onmouseenter="GoingsOn.dayPlan.onPaintMove(event, ${hour * 4 + quarter}, '${slotTimestamp}')" | |
| 57 | - | onclick="GoingsOn.dayPlan.onSlotTap(event, ${hour * 4 + quarter}, '${slotTimestamp}')"> | |
| 60 | + | onmousedown="GoingsOn.dayPlan.onPaintStart(event, ${hour * 4 + quarter}, '${escAttr(slotTimestamp)}')" | |
| 61 | + | onmouseenter="GoingsOn.dayPlan.onPaintMove(event, ${hour * 4 + quarter}, '${escAttr(slotTimestamp)}')" | |
| 62 | + | onclick="GoingsOn.dayPlan.onSlotTap(event, ${hour * 4 + quarter}, '${escAttr(slotTimestamp)}')"> | |
| 58 | 63 | <div class="timeline-time">${isHourStart ? timeStr : ''}</div> | |
| 59 | 64 | <div class="timeline-slot-area"></div> | |
| 60 | 65 | </div> | |
| @@ -111,6 +116,11 @@ | |||
| 111 | 116 | itemsContainer.innerHTML = itemsHtml; | |
| 112 | 117 | } | |
| 113 | 118 | ||
| 119 | + | /** | |
| 120 | + | * Render an unscheduled task item for the sidebar list. | |
| 121 | + | * @param {Object} task - Task object with id, description, priority, projectName | |
| 122 | + | * @returns {string} HTML string for the task item | |
| 123 | + | */ | |
| 114 | 124 | function renderUnscheduledTaskItem(task) { | |
| 115 | 125 | return ` | |
| 116 | 126 | <div class="unscheduled-task priority-${task.priority.toLowerCase()}" | |
| @@ -130,6 +140,12 @@ | |||
| 130 | 140 | `; | |
| 131 | 141 | } | |
| 132 | 142 | ||
| 143 | + | /** | |
| 144 | + | * Position the current-time indicator line and optionally scroll to it. | |
| 145 | + | * @param {Date} dayPlanDate - The date being displayed | |
| 146 | + | * @param {Function} formatDateForApi - (Date) => string formatter | |
| 147 | + | * @param {boolean} scrollToTime - true to scroll the timeline to current time | |
| 148 | + | */ | |
| 133 | 149 | function updateCurrentTimeIndicator(dayPlanDate, formatDateForApi, scrollToTime) { | |
| 134 | 150 | const indicator = document.getElementById('timeline-current-time'); | |
| 135 | 151 | const timelineContainer = document.getElementById('timeline-container'); |
| @@ -10,6 +10,10 @@ | |||
| 10 | 10 | ||
| 11 | 11 | // ============ Schedule Task Modal ============ | |
| 12 | 12 | ||
| 13 | + | /** | |
| 14 | + | * Open the schedule task modal with time slot picker and duration presets. | |
| 15 | + | * @param {string} id - Task ID to schedule | |
| 16 | + | */ | |
| 13 | 17 | function openScheduleTaskModal(id) { | |
| 14 | 18 | const now = new Date(); | |
| 15 | 19 | const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); | |
| @@ -74,6 +78,11 @@ | |||
| 74 | 78 | GoingsOn.ui.openModal('Schedule Time Block', content); | |
| 75 | 79 | } | |
| 76 | 80 | ||
| 81 | + | /** | |
| 82 | + | * Select a quick time slot and update the datetime input. | |
| 83 | + | * @param {HTMLElement} btn - Clicked button element | |
| 84 | + | * @param {string} isoTime - ISO 8601 timestamp for the slot | |
| 85 | + | */ | |
| 77 | 86 | function selectTimeSlot(btn, isoTime) { | |
| 78 | 87 | document.querySelectorAll('.time-block-quick-btn').forEach(b => b.classList.remove('selected')); | |
| 79 | 88 | btn.classList.add('selected'); | |
| @@ -81,12 +90,21 @@ | |||
| 81 | 90 | document.getElementById('schedule-datetime').value = datetime.toISOString().slice(0, 16); | |
| 82 | 91 | } | |
| 83 | 92 | ||
| 93 | + | /** | |
| 94 | + | * Select a duration preset and update the hidden input. | |
| 95 | + | * @param {HTMLElement} btn - Clicked button element | |
| 96 | + | * @param {number} minutes - Duration in minutes | |
| 97 | + | */ | |
| 84 | 98 | function selectDuration(btn, minutes) { | |
| 85 | 99 | document.querySelectorAll('.duration-preset').forEach(b => b.classList.remove('selected')); | |
| 86 | 100 | btn.classList.add('selected'); | |
| 87 | 101 | document.getElementById('schedule-duration').value = minutes; | |
| 88 | 102 | } | |
| 89 | 103 | ||
| 104 | + | /** | |
| 105 | + | * Submit the schedule task modal, creating the time block. | |
| 106 | + | * @param {string} id - Task ID to schedule | |
| 107 | + | */ | |
| 90 | 108 | async function scheduleTaskFromModal(id) { | |
| 91 | 109 | const datetimeInput = document.getElementById('schedule-datetime'); | |
| 92 | 110 | const durationInput = document.getElementById('schedule-duration'); |