taskchampion-rb 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.claude/settings.local.json +14 -0
- data/.rubocop.yml +21 -0
- data/CHANGELOG.md +15 -0
- data/Cargo.lock +3671 -0
- data/Cargo.toml +7 -0
- data/README.md +112 -0
- data/Rakefile +28 -0
- data/docs/API_REFERENCE.md +419 -0
- data/docs/THREAD_SAFETY.md +370 -0
- data/docs/breakthrough.md +246 -0
- data/docs/description.md +3 -0
- data/docs/phase_3_plan.md +482 -0
- data/docs/plan.md +612 -0
- data/example.md +465 -0
- data/examples/basic_usage.rb +278 -0
- data/examples/sync_workflow.rb +480 -0
- data/ext/taskchampion/Cargo.toml +20 -0
- data/ext/taskchampion/extconf.rb +6 -0
- data/ext/taskchampion/src/access_mode.rs +132 -0
- data/ext/taskchampion/src/annotation.rs +77 -0
- data/ext/taskchampion/src/dependency_map.rs +65 -0
- data/ext/taskchampion/src/error.rs +78 -0
- data/ext/taskchampion/src/lib.rs +41 -0
- data/ext/taskchampion/src/operation.rs +234 -0
- data/ext/taskchampion/src/operations.rs +180 -0
- data/ext/taskchampion/src/replica.rs +289 -0
- data/ext/taskchampion/src/status.rs +186 -0
- data/ext/taskchampion/src/tag.rs +77 -0
- data/ext/taskchampion/src/task.rs +388 -0
- data/ext/taskchampion/src/thread_check.rs +61 -0
- data/ext/taskchampion/src/util.rs +131 -0
- data/ext/taskchampion/src/working_set.rs +72 -0
- data/lib/taskchampion/version.rb +5 -0
- data/lib/taskchampion.rb +41 -0
- data/sig/taskchampion.rbs +4 -0
- data/taskchampion-0.2.0.gem +0 -0
- metadata +96 -0
@@ -0,0 +1,388 @@
|
|
1
|
+
use magnus::{
|
2
|
+
class, method, prelude::*, Error, IntoValue, RArray, RModule, Symbol, TryConvert, Value,
|
3
|
+
};
|
4
|
+
use taskchampion::Task as TCTask;
|
5
|
+
|
6
|
+
use crate::annotation::Annotation;
|
7
|
+
use crate::status::Status;
|
8
|
+
use crate::tag::Tag;
|
9
|
+
use crate::thread_check::ThreadBound;
|
10
|
+
use crate::util::{datetime_to_ruby, into_error, option_to_ruby, ruby_to_datetime, ruby_to_option, vec_to_ruby};
|
11
|
+
|
12
|
+
#[magnus::wrap(class = "Taskchampion::Task", free_immediately)]
|
13
|
+
pub struct Task(ThreadBound<TCTask>);
|
14
|
+
|
15
|
+
impl Task {
|
16
|
+
pub fn from_tc_task(tc_task: TCTask) -> Self {
|
17
|
+
Task(ThreadBound::new(tc_task))
|
18
|
+
}
|
19
|
+
|
20
|
+
fn inspect(&self) -> Result<String, Error> {
|
21
|
+
let task = self.0.get()?;
|
22
|
+
Ok(format!("#<Taskchampion::Task: {}>", task.get_uuid()))
|
23
|
+
}
|
24
|
+
|
25
|
+
fn uuid(&self) -> Result<String, Error> {
|
26
|
+
let task = self.0.get()?;
|
27
|
+
Ok(task.get_uuid().to_string())
|
28
|
+
}
|
29
|
+
|
30
|
+
fn status(&self) -> Result<Symbol, Error> {
|
31
|
+
let task = self.0.get()?;
|
32
|
+
Ok(Status::from(task.get_status()).to_symbol())
|
33
|
+
}
|
34
|
+
|
35
|
+
fn description(&self) -> Result<String, Error> {
|
36
|
+
let task = self.0.get()?;
|
37
|
+
Ok(task.get_description().to_string())
|
38
|
+
}
|
39
|
+
|
40
|
+
fn entry(&self) -> Result<Value, Error> {
|
41
|
+
let task = self.0.get()?;
|
42
|
+
option_to_ruby(task.get_entry(), datetime_to_ruby)
|
43
|
+
}
|
44
|
+
|
45
|
+
fn priority(&self) -> Result<String, Error> {
|
46
|
+
let task = self.0.get()?;
|
47
|
+
Ok(task.get_priority().to_string())
|
48
|
+
}
|
49
|
+
|
50
|
+
fn wait(&self) -> Result<Value, Error> {
|
51
|
+
let task = self.0.get()?;
|
52
|
+
option_to_ruby(task.get_wait(), datetime_to_ruby)
|
53
|
+
}
|
54
|
+
|
55
|
+
fn modified(&self) -> Result<Value, Error> {
|
56
|
+
let task = self.0.get()?;
|
57
|
+
option_to_ruby(task.get_modified(), datetime_to_ruby)
|
58
|
+
}
|
59
|
+
|
60
|
+
fn due(&self) -> Result<Value, Error> {
|
61
|
+
let task = self.0.get()?;
|
62
|
+
option_to_ruby(task.get_due(), datetime_to_ruby)
|
63
|
+
}
|
64
|
+
|
65
|
+
fn dependencies(&self) -> Result<RArray, Error> {
|
66
|
+
let task = self.0.get()?;
|
67
|
+
let deps: Vec<String> = task.get_dependencies().map(|uuid| uuid.to_string()).collect();
|
68
|
+
vec_to_ruby(deps, |s| Ok(s.into_value()))
|
69
|
+
}
|
70
|
+
|
71
|
+
// Boolean methods with ? suffix
|
72
|
+
fn waiting(&self) -> Result<bool, Error> {
|
73
|
+
let task = self.0.get()?;
|
74
|
+
Ok(task.is_waiting())
|
75
|
+
}
|
76
|
+
|
77
|
+
fn active(&self) -> Result<bool, Error> {
|
78
|
+
let task = self.0.get()?;
|
79
|
+
Ok(task.is_active())
|
80
|
+
}
|
81
|
+
|
82
|
+
fn blocked(&self) -> Result<bool, Error> {
|
83
|
+
let task = self.0.get()?;
|
84
|
+
Ok(task.is_blocked())
|
85
|
+
}
|
86
|
+
|
87
|
+
fn blocking(&self) -> Result<bool, Error> {
|
88
|
+
let task = self.0.get()?;
|
89
|
+
Ok(task.is_blocking())
|
90
|
+
}
|
91
|
+
|
92
|
+
fn completed(&self) -> Result<bool, Error> {
|
93
|
+
let task = self.0.get()?;
|
94
|
+
Ok(task.get_status() == taskchampion::Status::Completed)
|
95
|
+
}
|
96
|
+
|
97
|
+
fn deleted(&self) -> Result<bool, Error> {
|
98
|
+
let task = self.0.get()?;
|
99
|
+
Ok(task.get_status() == taskchampion::Status::Deleted)
|
100
|
+
}
|
101
|
+
|
102
|
+
fn pending(&self) -> Result<bool, Error> {
|
103
|
+
let task = self.0.get()?;
|
104
|
+
Ok(task.get_status() == taskchampion::Status::Pending)
|
105
|
+
}
|
106
|
+
|
107
|
+
// Tag methods
|
108
|
+
fn has_tag(&self, tag: &Tag) -> Result<bool, Error> {
|
109
|
+
let task = self.0.get()?;
|
110
|
+
Ok(task.has_tag(tag.as_ref()))
|
111
|
+
}
|
112
|
+
|
113
|
+
fn tags(&self) -> Result<RArray, Error> {
|
114
|
+
let task = self.0.get()?;
|
115
|
+
let tags: Vec<Tag> = task.get_tags().map(Tag::from).collect();
|
116
|
+
vec_to_ruby(tags, |tag| {
|
117
|
+
Ok(tag.into_value()) // Convert to Value using IntoValue trait
|
118
|
+
})
|
119
|
+
}
|
120
|
+
|
121
|
+
fn annotations(&self) -> Result<RArray, Error> {
|
122
|
+
let task = self.0.get()?;
|
123
|
+
let annotations: Vec<Annotation> = task.get_annotations().map(Annotation::from).collect();
|
124
|
+
vec_to_ruby(annotations, |ann| {
|
125
|
+
Ok(ann.into_value()) // Convert to Value using IntoValue trait
|
126
|
+
})
|
127
|
+
}
|
128
|
+
|
129
|
+
// Value access
|
130
|
+
fn get_value(&self, property: String) -> Result<Value, Error> {
|
131
|
+
let task = self.0.get()?;
|
132
|
+
match task.get_value(property) {
|
133
|
+
Some(value) => Ok(value.into_value()),
|
134
|
+
None => Ok(().into_value()), // () converts to nil in Magnus
|
135
|
+
}
|
136
|
+
}
|
137
|
+
|
138
|
+
fn get_uda(&self, namespace: String, key: String) -> Result<Value, Error> {
|
139
|
+
let task = self.0.get()?;
|
140
|
+
match task.get_uda(&namespace, &key) {
|
141
|
+
Some(value) => Ok(value.into_value()),
|
142
|
+
None => Ok(().into_value()), // () converts to nil in Magnus
|
143
|
+
}
|
144
|
+
}
|
145
|
+
|
146
|
+
fn udas(&self) -> Result<RArray, Error> {
|
147
|
+
let task = self.0.get()?;
|
148
|
+
let udas: Vec<((String, String), String)> = task.get_udas()
|
149
|
+
.map(|((ns, key), value)| ((ns.to_string(), key.to_string()), value.to_string()))
|
150
|
+
.collect();
|
151
|
+
|
152
|
+
vec_to_ruby(udas, |(key_tuple, value)| {
|
153
|
+
let array = RArray::new();
|
154
|
+
let key_array = RArray::new();
|
155
|
+
key_array.push(key_tuple.0)?;
|
156
|
+
key_array.push(key_tuple.1)?;
|
157
|
+
array.push(key_array)?;
|
158
|
+
array.push(value)?;
|
159
|
+
Ok(array.into_value())
|
160
|
+
})
|
161
|
+
}
|
162
|
+
|
163
|
+
// Mutation methods that require Operations parameter
|
164
|
+
fn set_description(&self, description: String, operations: &crate::operations::Operations) -> Result<(), Error> {
|
165
|
+
if description.trim().is_empty() {
|
166
|
+
return Err(Error::new(
|
167
|
+
crate::error::validation_error(),
|
168
|
+
"Description cannot be empty or whitespace-only"
|
169
|
+
));
|
170
|
+
}
|
171
|
+
|
172
|
+
let mut task = self.0.get_mut()?;
|
173
|
+
operations.with_inner_mut(|ops| {
|
174
|
+
task.set_description(description.clone(), ops)
|
175
|
+
})?;
|
176
|
+
Ok(())
|
177
|
+
}
|
178
|
+
|
179
|
+
fn set_status(&self, status: Value, operations: &crate::operations::Operations) -> Result<(), Error> {
|
180
|
+
let mut task = self.0.get_mut()?;
|
181
|
+
|
182
|
+
// Handle both Status objects and symbols
|
183
|
+
let status = if let Ok(status_obj) = <&Status>::try_convert(status) {
|
184
|
+
*status_obj // Copy the Status object
|
185
|
+
} else if let Ok(symbol) = Symbol::try_convert(status) {
|
186
|
+
Status::from_symbol(symbol)?
|
187
|
+
} else {
|
188
|
+
return Err(Error::new(
|
189
|
+
crate::error::validation_error(),
|
190
|
+
"Status must be a Taskchampion::Status object or a symbol (:pending, :completed, :deleted, etc.)"
|
191
|
+
));
|
192
|
+
};
|
193
|
+
|
194
|
+
operations.with_inner_mut(|ops| {
|
195
|
+
task.set_status(status.into(), ops)
|
196
|
+
})?;
|
197
|
+
Ok(())
|
198
|
+
}
|
199
|
+
|
200
|
+
fn set_priority(&self, priority: String, operations: &crate::operations::Operations) -> Result<(), Error> {
|
201
|
+
if priority.trim().is_empty() {
|
202
|
+
return Err(Error::new(
|
203
|
+
crate::error::validation_error(),
|
204
|
+
"Priority cannot be empty or whitespace-only"
|
205
|
+
));
|
206
|
+
}
|
207
|
+
|
208
|
+
let mut task = self.0.get_mut()?;
|
209
|
+
operations.with_inner_mut(|ops| {
|
210
|
+
task.set_priority(priority.clone(), ops)
|
211
|
+
})?;
|
212
|
+
Ok(())
|
213
|
+
}
|
214
|
+
|
215
|
+
fn add_tag(&self, tag: &Tag, operations: &crate::operations::Operations) -> Result<(), Error> {
|
216
|
+
let mut task = self.0.get_mut()?;
|
217
|
+
operations.with_inner_mut(|ops| {
|
218
|
+
task.add_tag(tag.as_ref(), ops)
|
219
|
+
})?;
|
220
|
+
Ok(())
|
221
|
+
}
|
222
|
+
|
223
|
+
fn remove_tag(&self, tag: &Tag, operations: &crate::operations::Operations) -> Result<(), Error> {
|
224
|
+
let mut task = self.0.get_mut()?;
|
225
|
+
operations.with_inner_mut(|ops| {
|
226
|
+
task.remove_tag(tag.as_ref(), ops)
|
227
|
+
})?;
|
228
|
+
Ok(())
|
229
|
+
}
|
230
|
+
|
231
|
+
fn add_annotation(&self, description: String, operations: &crate::operations::Operations) -> Result<(), Error> {
|
232
|
+
if description.trim().is_empty() {
|
233
|
+
return Err(Error::new(
|
234
|
+
crate::error::validation_error(),
|
235
|
+
"Annotation description cannot be empty or whitespace-only"
|
236
|
+
));
|
237
|
+
}
|
238
|
+
|
239
|
+
let mut task = self.0.get_mut()?;
|
240
|
+
use chrono::Utc;
|
241
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
242
|
+
|
243
|
+
// Use an atomic counter to ensure unique second-level timestamps
|
244
|
+
// TaskChampion appears to truncate sub-second precision in property keys
|
245
|
+
static ANNOTATION_COUNTER: AtomicU64 = AtomicU64::new(0);
|
246
|
+
let counter = ANNOTATION_COUNTER.fetch_add(1, Ordering::SeqCst);
|
247
|
+
|
248
|
+
// Get current time and add second offset to ensure uniqueness at TaskChampion's precision level
|
249
|
+
let base_time = Utc::now();
|
250
|
+
let now = base_time + chrono::Duration::seconds(counter as i64);
|
251
|
+
let annotation = taskchampion::Annotation { entry: now, description: description.clone() };
|
252
|
+
operations.with_inner_mut(|ops| {
|
253
|
+
task.add_annotation(annotation, ops)
|
254
|
+
})?;
|
255
|
+
Ok(())
|
256
|
+
}
|
257
|
+
|
258
|
+
fn set_due(&self, due: Value, operations: &crate::operations::Operations) -> Result<(), Error> {
|
259
|
+
let mut task = self.0.get_mut()?;
|
260
|
+
let due_datetime = ruby_to_option(due, ruby_to_datetime)?;
|
261
|
+
operations.with_inner_mut(|ops| {
|
262
|
+
task.set_due(due_datetime, ops)
|
263
|
+
})?;
|
264
|
+
Ok(())
|
265
|
+
}
|
266
|
+
|
267
|
+
fn set_value(&self, property: String, value: Value, operations: &crate::operations::Operations) -> Result<(), Error> {
|
268
|
+
if property.trim().is_empty() {
|
269
|
+
return Err(Error::new(
|
270
|
+
crate::error::validation_error(),
|
271
|
+
"Property name cannot be empty or whitespace-only"
|
272
|
+
));
|
273
|
+
}
|
274
|
+
|
275
|
+
let mut task = self.0.get_mut()?;
|
276
|
+
let value_str = if value.is_nil() {
|
277
|
+
None
|
278
|
+
} else {
|
279
|
+
Some(value.to_string())
|
280
|
+
};
|
281
|
+
operations.with_inner_mut(|ops| {
|
282
|
+
task.set_value(&property, value_str, ops)
|
283
|
+
})?;
|
284
|
+
Ok(())
|
285
|
+
}
|
286
|
+
|
287
|
+
fn set_uda(&self, namespace: String, key: String, value: String, operations: &crate::operations::Operations) -> Result<(), Error> {
|
288
|
+
if namespace.trim().is_empty() {
|
289
|
+
return Err(Error::new(
|
290
|
+
crate::error::validation_error(),
|
291
|
+
"UDA namespace cannot be empty or whitespace-only"
|
292
|
+
));
|
293
|
+
}
|
294
|
+
if key.trim().is_empty() {
|
295
|
+
return Err(Error::new(
|
296
|
+
crate::error::validation_error(),
|
297
|
+
"UDA key cannot be empty or whitespace-only"
|
298
|
+
));
|
299
|
+
}
|
300
|
+
|
301
|
+
let mut task = self.0.get_mut()?;
|
302
|
+
operations.with_inner_mut(|ops| {
|
303
|
+
task.set_uda(&namespace, &key, &value, ops)
|
304
|
+
})?;
|
305
|
+
Ok(())
|
306
|
+
}
|
307
|
+
|
308
|
+
fn delete_uda(&self, namespace: String, key: String, operations: &crate::operations::Operations) -> Result<(), Error> {
|
309
|
+
if namespace.trim().is_empty() {
|
310
|
+
return Err(Error::new(
|
311
|
+
crate::error::validation_error(),
|
312
|
+
"UDA namespace cannot be empty or whitespace-only"
|
313
|
+
));
|
314
|
+
}
|
315
|
+
if key.trim().is_empty() {
|
316
|
+
return Err(Error::new(
|
317
|
+
crate::error::validation_error(),
|
318
|
+
"UDA key cannot be empty or whitespace-only"
|
319
|
+
));
|
320
|
+
}
|
321
|
+
|
322
|
+
let mut task = self.0.get_mut()?;
|
323
|
+
operations.with_inner_mut(|ops| {
|
324
|
+
task.remove_uda(&namespace, &key, ops)
|
325
|
+
})?;
|
326
|
+
Ok(())
|
327
|
+
}
|
328
|
+
|
329
|
+
}
|
330
|
+
|
331
|
+
// Remove AsRef implementation as it doesn't work well with thread bounds
|
332
|
+
// Use direct method calls instead
|
333
|
+
|
334
|
+
impl From<TCTask> for Task {
|
335
|
+
fn from(value: TCTask) -> Self {
|
336
|
+
Task(ThreadBound::new(value))
|
337
|
+
}
|
338
|
+
}
|
339
|
+
|
340
|
+
pub fn init(module: &RModule) -> Result<(), Error> {
|
341
|
+
let class = module.define_class("Task", class::object())?;
|
342
|
+
|
343
|
+
// Property methods (Ruby idiomatic - no get_ prefix)
|
344
|
+
class.define_method("inspect", method!(Task::inspect, 0))?;
|
345
|
+
class.define_method("uuid", method!(Task::uuid, 0))?;
|
346
|
+
class.define_method("status", method!(Task::status, 0))?;
|
347
|
+
class.define_method("description", method!(Task::description, 0))?;
|
348
|
+
class.define_method("entry", method!(Task::entry, 0))?;
|
349
|
+
class.define_method("priority", method!(Task::priority, 0))?;
|
350
|
+
class.define_method("wait", method!(Task::wait, 0))?;
|
351
|
+
class.define_method("modified", method!(Task::modified, 0))?;
|
352
|
+
class.define_method("due", method!(Task::due, 0))?;
|
353
|
+
class.define_method("dependencies", method!(Task::dependencies, 0))?;
|
354
|
+
|
355
|
+
// Boolean methods with ? suffix
|
356
|
+
class.define_method("waiting?", method!(Task::waiting, 0))?;
|
357
|
+
class.define_method("active?", method!(Task::active, 0))?;
|
358
|
+
class.define_method("blocked?", method!(Task::blocked, 0))?;
|
359
|
+
class.define_method("blocking?", method!(Task::blocking, 0))?;
|
360
|
+
class.define_method("completed?", method!(Task::completed, 0))?;
|
361
|
+
class.define_method("deleted?", method!(Task::deleted, 0))?;
|
362
|
+
class.define_method("pending?", method!(Task::pending, 0))?;
|
363
|
+
|
364
|
+
// Tag methods
|
365
|
+
class.define_method("has_tag?", method!(Task::has_tag, 1))?;
|
366
|
+
class.define_method("tags", method!(Task::tags, 0))?;
|
367
|
+
class.define_method("annotations", method!(Task::annotations, 0))?;
|
368
|
+
|
369
|
+
// Value access - Ruby convention: no get_ prefix
|
370
|
+
class.define_method("value", method!(Task::get_value, 1))?;
|
371
|
+
class.define_method("get_value", method!(Task::get_value, 1))?; // Keep for backward compatibility
|
372
|
+
class.define_method("uda", method!(Task::get_uda, 2))?;
|
373
|
+
class.define_method("get_uda", method!(Task::get_uda, 2))?; // Keep for backward compatibility
|
374
|
+
class.define_method("udas", method!(Task::udas, 0))?;
|
375
|
+
|
376
|
+
// Mutation methods
|
377
|
+
class.define_method("set_description", method!(Task::set_description, 2))?;
|
378
|
+
class.define_method("set_status", method!(Task::set_status, 2))?;
|
379
|
+
class.define_method("set_priority", method!(Task::set_priority, 2))?;
|
380
|
+
class.define_method("add_tag", method!(Task::add_tag, 2))?;
|
381
|
+
class.define_method("remove_tag", method!(Task::remove_tag, 2))?;
|
382
|
+
class.define_method("add_annotation", method!(Task::add_annotation, 2))?;
|
383
|
+
class.define_method("set_due", method!(Task::set_due, 2))?;
|
384
|
+
class.define_method("set_value", method!(Task::set_value, 3))?;
|
385
|
+
class.define_method("set_uda", method!(Task::set_uda, 4))?;
|
386
|
+
class.define_method("delete_uda", method!(Task::delete_uda, 3))?;
|
387
|
+
Ok(())
|
388
|
+
}
|
@@ -0,0 +1,61 @@
|
|
1
|
+
use magnus::Error;
|
2
|
+
use std::thread::ThreadId;
|
3
|
+
use std::cell::RefCell;
|
4
|
+
use crate::error::thread_error;
|
5
|
+
|
6
|
+
pub struct ThreadBound<T> {
|
7
|
+
inner: RefCell<T>,
|
8
|
+
thread_id: ThreadId,
|
9
|
+
}
|
10
|
+
|
11
|
+
// SAFETY: ThreadBound ensures thread-local access only
|
12
|
+
// The RefCell prevents concurrent access from the same thread
|
13
|
+
// The thread_id check prevents access from different threads
|
14
|
+
unsafe impl<T> Send for ThreadBound<T> {}
|
15
|
+
unsafe impl<T> Sync for ThreadBound<T> {}
|
16
|
+
|
17
|
+
impl<T> ThreadBound<T> {
|
18
|
+
pub fn new(inner: T) -> Self {
|
19
|
+
Self {
|
20
|
+
inner: RefCell::new(inner),
|
21
|
+
thread_id: std::thread::current().id(),
|
22
|
+
}
|
23
|
+
}
|
24
|
+
|
25
|
+
pub fn check_thread(&self) -> Result<(), Error> {
|
26
|
+
if self.thread_id != std::thread::current().id() {
|
27
|
+
return Err(Error::new(
|
28
|
+
thread_error(),
|
29
|
+
"Object cannot be accessed from a different thread",
|
30
|
+
));
|
31
|
+
}
|
32
|
+
Ok(())
|
33
|
+
}
|
34
|
+
|
35
|
+
pub fn get(&self) -> Result<std::cell::Ref<T>, Error> {
|
36
|
+
self.check_thread()?;
|
37
|
+
Ok(self.inner.borrow())
|
38
|
+
}
|
39
|
+
|
40
|
+
pub fn get_mut(&self) -> Result<std::cell::RefMut<T>, Error> {
|
41
|
+
self.check_thread()?;
|
42
|
+
Ok(self.inner.borrow_mut())
|
43
|
+
}
|
44
|
+
|
45
|
+
pub fn into_inner(self) -> Result<T, Error> {
|
46
|
+
if self.thread_id != std::thread::current().id() {
|
47
|
+
return Err(Error::new(
|
48
|
+
thread_error(),
|
49
|
+
"Object cannot be extracted from a different thread",
|
50
|
+
));
|
51
|
+
}
|
52
|
+
Ok(self.inner.into_inner())
|
53
|
+
}
|
54
|
+
}
|
55
|
+
|
56
|
+
#[macro_export]
|
57
|
+
macro_rules! check_thread {
|
58
|
+
($self:expr) => {
|
59
|
+
$self.0.check_thread()?;
|
60
|
+
};
|
61
|
+
}
|
@@ -0,0 +1,131 @@
|
|
1
|
+
use magnus::{Error, Value, RString, RHash, RArray, IntoValue, prelude::*};
|
2
|
+
use taskchampion::Uuid;
|
3
|
+
use chrono::{DateTime, Utc};
|
4
|
+
use std::collections::HashMap;
|
5
|
+
use crate::error::validation_error;
|
6
|
+
|
7
|
+
/// Convert a string from Ruby into a Rust Uuid with enhanced validation
|
8
|
+
pub fn uuid2tc(s: impl AsRef<str>) -> Result<Uuid, Error> {
|
9
|
+
let uuid_str = s.as_ref();
|
10
|
+
Uuid::parse_str(uuid_str)
|
11
|
+
.map_err(|_| Error::new(
|
12
|
+
validation_error(),
|
13
|
+
format!("Invalid UUID format: '{}'. Expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", uuid_str)
|
14
|
+
))
|
15
|
+
}
|
16
|
+
|
17
|
+
/// Convert a taskchampion::Error into a Ruby error with enhanced mapping
|
18
|
+
pub fn into_error(err: taskchampion::Error) -> Error {
|
19
|
+
crate::error::map_taskchampion_error(err)
|
20
|
+
}
|
21
|
+
|
22
|
+
/// Convert Rust DateTime<Utc> to Ruby DateTime
|
23
|
+
pub fn datetime_to_ruby(dt: DateTime<Utc>) -> Result<Value, Error> {
|
24
|
+
let ruby = magnus::Ruby::get().map_err(|e| Error::new(magnus::exception::runtime_error(), e.to_string()))?;
|
25
|
+
let datetime_class: Value = ruby.eval("require 'date'; DateTime")?;
|
26
|
+
|
27
|
+
// Convert to string and parse in Ruby (simplest approach)
|
28
|
+
let iso_string = dt.to_rfc3339();
|
29
|
+
datetime_class.funcall("parse", (iso_string,))
|
30
|
+
}
|
31
|
+
|
32
|
+
/// Convert Ruby DateTime/Time/String to Rust DateTime<Utc> with enhanced validation
|
33
|
+
pub fn ruby_to_datetime(value: Value) -> Result<DateTime<Utc>, Error> {
|
34
|
+
let ruby = magnus::Ruby::get().map_err(|e| Error::new(magnus::exception::runtime_error(), e.to_string()))?;
|
35
|
+
|
36
|
+
// If it's a string, parse it
|
37
|
+
if let Ok(s) = RString::try_convert(value) {
|
38
|
+
let s = unsafe { s.as_str()? };
|
39
|
+
DateTime::parse_from_rfc3339(s)
|
40
|
+
.map(|dt| dt.with_timezone(&Utc))
|
41
|
+
.or_else(|_| DateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S %z")
|
42
|
+
.map(|dt| dt.with_timezone(&Utc)))
|
43
|
+
.map_err(|_| Error::new(
|
44
|
+
validation_error(),
|
45
|
+
format!("Invalid datetime format: '{}'. Expected ISO 8601 format (e.g., '2023-01-01T12:00:00Z') or '%Y-%m-%d %H:%M:%S %z'", s)
|
46
|
+
))
|
47
|
+
} else {
|
48
|
+
// Convert Ruby DateTime/Time to ISO string then parse
|
49
|
+
match value.funcall::<_, (), String>("iso8601", ()) {
|
50
|
+
Ok(iso_string) => {
|
51
|
+
DateTime::parse_from_rfc3339(&iso_string)
|
52
|
+
.map(|dt| dt.with_timezone(&Utc))
|
53
|
+
.map_err(|_| Error::new(
|
54
|
+
validation_error(),
|
55
|
+
format!("Invalid datetime from Ruby object: '{}'. Unable to parse as ISO 8601", iso_string)
|
56
|
+
))
|
57
|
+
}
|
58
|
+
Err(_) => Err(Error::new(
|
59
|
+
validation_error(),
|
60
|
+
format!("Cannot convert value to datetime. Expected Time, DateTime, or String, got: {}", value.class().inspect())
|
61
|
+
))
|
62
|
+
}
|
63
|
+
}
|
64
|
+
}
|
65
|
+
|
66
|
+
/// Convert Option<T> to Ruby value (nil for None)
|
67
|
+
pub fn option_to_ruby<T, F>(opt: Option<T>, converter: F) -> Result<Value, Error>
|
68
|
+
where
|
69
|
+
F: FnOnce(T) -> Result<Value, Error>,
|
70
|
+
{
|
71
|
+
match opt {
|
72
|
+
Some(val) => converter(val),
|
73
|
+
None => Ok(().into_value()), // () converts to nil in Magnus
|
74
|
+
}
|
75
|
+
}
|
76
|
+
|
77
|
+
/// Convert Ruby value to Option<T> (nil becomes None)
|
78
|
+
pub fn ruby_to_option<T, F>(value: Value, converter: F) -> Result<Option<T>, Error>
|
79
|
+
where
|
80
|
+
F: FnOnce(Value) -> Result<T, Error>,
|
81
|
+
{
|
82
|
+
if value.is_nil() {
|
83
|
+
Ok(None)
|
84
|
+
} else {
|
85
|
+
converter(value).map(Some)
|
86
|
+
}
|
87
|
+
}
|
88
|
+
|
89
|
+
/// Convert HashMap to Ruby Hash
|
90
|
+
pub fn hashmap_to_ruby(map: HashMap<String, String>) -> Result<RHash, Error> {
|
91
|
+
let hash = RHash::new();
|
92
|
+
for (k, v) in map {
|
93
|
+
hash.aset(k, v)?;
|
94
|
+
}
|
95
|
+
Ok(hash)
|
96
|
+
}
|
97
|
+
|
98
|
+
/// Convert Ruby Hash to HashMap
|
99
|
+
pub fn ruby_to_hashmap(hash: RHash) -> Result<HashMap<String, String>, Error> {
|
100
|
+
let mut map = HashMap::new();
|
101
|
+
hash.foreach(|key: String, value: String| {
|
102
|
+
map.insert(key, value);
|
103
|
+
Ok(magnus::r_hash::ForEach::Continue)
|
104
|
+
})?;
|
105
|
+
Ok(map)
|
106
|
+
}
|
107
|
+
|
108
|
+
/// Convert Vec to Ruby Array
|
109
|
+
pub fn vec_to_ruby<T, F>(vec: Vec<T>, converter: F) -> Result<RArray, Error>
|
110
|
+
where
|
111
|
+
F: Fn(T) -> Result<Value, Error>,
|
112
|
+
{
|
113
|
+
let array = RArray::with_capacity(vec.len());
|
114
|
+
for item in vec {
|
115
|
+
array.push(converter(item)?)?;
|
116
|
+
}
|
117
|
+
Ok(array)
|
118
|
+
}
|
119
|
+
|
120
|
+
/// Convert Ruby Array to Vec
|
121
|
+
pub fn ruby_to_vec<T, F>(array: RArray, converter: F) -> Result<Vec<T>, Error>
|
122
|
+
where
|
123
|
+
F: Fn(Value) -> Result<T, Error>,
|
124
|
+
{
|
125
|
+
let mut vec = Vec::with_capacity(array.len());
|
126
|
+
for i in 0..array.len() {
|
127
|
+
let value: Value = array.entry(i as isize)?;
|
128
|
+
vec.push(converter(value)?);
|
129
|
+
}
|
130
|
+
Ok(vec)
|
131
|
+
}
|
@@ -0,0 +1,72 @@
|
|
1
|
+
use magnus::{
|
2
|
+
class, method, prelude::*, Error, IntoValue, RModule, Value,
|
3
|
+
};
|
4
|
+
use std::sync::Arc;
|
5
|
+
use taskchampion::WorkingSet as TCWorkingSet;
|
6
|
+
|
7
|
+
use crate::thread_check::ThreadBound;
|
8
|
+
|
9
|
+
#[magnus::wrap(class = "Taskchampion::WorkingSet", free_immediately)]
|
10
|
+
pub struct WorkingSet(ThreadBound<Arc<TCWorkingSet>>);
|
11
|
+
|
12
|
+
impl WorkingSet {
|
13
|
+
pub fn from_tc_working_set(tc_working_set: Arc<TCWorkingSet>) -> Self {
|
14
|
+
WorkingSet(ThreadBound::new(tc_working_set))
|
15
|
+
}
|
16
|
+
|
17
|
+
fn largest_index(&self) -> Result<usize, Error> {
|
18
|
+
let working_set = self.0.get()?;
|
19
|
+
Ok(working_set.largest_index())
|
20
|
+
}
|
21
|
+
|
22
|
+
fn by_index(&self, index: usize) -> Result<Value, Error> {
|
23
|
+
let working_set = self.0.get()?;
|
24
|
+
match working_set.by_index(index) {
|
25
|
+
Some(uuid) => {
|
26
|
+
// For now, return the UUID string - we'll need to solve the task lookup problem
|
27
|
+
// in a different way since WorkingSet doesn't have access to the replica
|
28
|
+
Ok(uuid.to_string().into_value())
|
29
|
+
}
|
30
|
+
None => Ok(().into_value()),
|
31
|
+
}
|
32
|
+
}
|
33
|
+
fn by_uuid(&self, uuid: String) -> Result<Value, Error> {
|
34
|
+
let working_set = self.0.get()?;
|
35
|
+
let tc_uuid = crate::util::uuid2tc(&uuid)?;
|
36
|
+
|
37
|
+
match working_set.by_uuid(tc_uuid) {
|
38
|
+
Some(index) => Ok(index.into_value()),
|
39
|
+
None => Ok(().into_value()),
|
40
|
+
}
|
41
|
+
}
|
42
|
+
|
43
|
+
fn renumber(&self) -> Result<(), Error> {
|
44
|
+
let _working_set = self.0.get()?;
|
45
|
+
// Note: renumber requires &mut self in TaskChampion, but WorkingSet is immutable
|
46
|
+
// This is a limitation we'll need to work around or document
|
47
|
+
Err(Error::new(
|
48
|
+
magnus::exception::runtime_error(),
|
49
|
+
"WorkingSet renumber is not implemented due to mutability constraints",
|
50
|
+
))
|
51
|
+
}
|
52
|
+
|
53
|
+
fn inspect(&self) -> Result<String, Error> {
|
54
|
+
let working_set = self.0.get()?;
|
55
|
+
Ok(format!(
|
56
|
+
"#<Taskchampion::WorkingSet: largest_index={}>",
|
57
|
+
working_set.largest_index()
|
58
|
+
))
|
59
|
+
}
|
60
|
+
}
|
61
|
+
|
62
|
+
pub fn init(module: &RModule) -> Result<(), Error> {
|
63
|
+
let class = module.define_class("WorkingSet", class::object())?;
|
64
|
+
|
65
|
+
class.define_method("largest_index", method!(WorkingSet::largest_index, 0))?;
|
66
|
+
class.define_method("by_index", method!(WorkingSet::by_index, 1))?;
|
67
|
+
class.define_method("by_uuid", method!(WorkingSet::by_uuid, 1))?;
|
68
|
+
class.define_method("renumber", method!(WorkingSet::renumber, 0))?;
|
69
|
+
class.define_method("inspect", method!(WorkingSet::inspect, 0))?;
|
70
|
+
|
71
|
+
Ok(())
|
72
|
+
}
|
data/lib/taskchampion.rb
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "taskchampion/version"
|
4
|
+
require_relative "taskchampion/taskchampion" # This loads the Rust extension
|
5
|
+
|
6
|
+
module Taskchampion
|
7
|
+
# Error classes are defined in Rust
|
8
|
+
# Replica class is defined in Rust
|
9
|
+
# Additional Ruby-level helpers can be added here
|
10
|
+
|
11
|
+
# Override WorkingSet to add task lookup functionality
|
12
|
+
class WorkingSet
|
13
|
+
# Store replica reference for task lookup
|
14
|
+
attr_accessor :replica
|
15
|
+
|
16
|
+
alias_method :_original_by_index, :by_index
|
17
|
+
|
18
|
+
def by_index(index)
|
19
|
+
uuid_string = _original_by_index(index)
|
20
|
+
return nil if uuid_string.nil? || uuid_string.to_s.empty?
|
21
|
+
|
22
|
+
# If we have a replica reference, look up the actual task
|
23
|
+
if @replica
|
24
|
+
@replica.task(uuid_string)
|
25
|
+
else
|
26
|
+
uuid_string
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Override the Replica class to set the replica reference
|
32
|
+
class Replica
|
33
|
+
alias_method :_original_working_set, :working_set
|
34
|
+
|
35
|
+
def working_set
|
36
|
+
ws = _original_working_set
|
37
|
+
ws.replica = self
|
38
|
+
ws
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|