1use anyhow::{Context, Result};
15use chrono::Utc;
16use parking_lot::RwLock;
17use rocksdb::{ColumnFamily, ColumnFamilyDescriptor, Options, WriteBatch, DB};
18use std::collections::HashMap;
19use std::path::Path;
20use std::sync::Arc;
21use uuid::Uuid;
22
23use super::types::{
24 Project, ProjectId, ProjectStatus, Todo, TodoComment, TodoCommentId, TodoCommentType, TodoId,
25 TodoStatus,
26};
27use crate::vector_db::{VamanaConfig, VamanaIndex};
28
29const EMBEDDING_DIM: usize = 384;
31
32const CF_TODOS: &str = "todos";
33const CF_PROJECTS: &str = "projects";
34const CF_TODO_INDEX: &str = "todo_index";
35
36fn migrate_due_key_padding(db: &DB, index_cf: &ColumnFamily) -> Result<usize> {
42 let mut batch = WriteBatch::default();
43 let mut count = 0;
44
45 for item in db.prefix_iterator_cf(index_cf, b"due:") {
46 let (key, value) = item.context("Failed to read due index during migration")?;
47 let key_str = std::str::from_utf8(&key).context("Non-UTF8 key in todo due index")?;
48
49 let parts: Vec<&str> = key_str.splitn(4, ':').collect();
51 if parts.len() != 4 {
52 continue;
53 }
54
55 if parts[1].len() >= 20 {
57 continue;
58 }
59
60 if let Ok(ts) = parts[1].parse::<i64>() {
61 let new_key = format!("due:{:020}:{}:{}", ts, parts[2], parts[3]);
62 batch.delete_cf(index_cf, &*key);
63 batch.put_cf(index_cf, new_key.as_bytes(), &*value);
64 count += 1;
65 }
66 }
67
68 if count > 0 {
69 db.write(batch)
70 .context("Failed to write migrated todo due keys")?;
71 tracing::info!(count, "Migrated todo due keys to zero-padded format");
72 }
73
74 Ok(count)
75}
76
77pub struct TodoStore {
79 db: Arc<DB>,
81 vector_indices: RwLock<HashMap<String, VamanaIndex>>,
83 storage_path: std::path::PathBuf,
85 seq_mutex: parking_lot::Mutex<()>,
87}
88
89impl TodoStore {
90 pub fn cf_descriptors() -> Vec<ColumnFamilyDescriptor> {
93 let mut cf_opts = Options::default();
94 cf_opts.create_if_missing(true);
95 cf_opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
96 vec![
97 ColumnFamilyDescriptor::new(CF_TODOS, cf_opts.clone()),
98 ColumnFamilyDescriptor::new(CF_PROJECTS, cf_opts.clone()),
99 ColumnFamilyDescriptor::new(CF_TODO_INDEX, cf_opts),
100 ]
101 }
102
103 fn todos_cf(&self) -> &ColumnFamily {
104 self.db.cf_handle(CF_TODOS).expect("todos CF must exist")
105 }
106 fn projects_cf(&self) -> &ColumnFamily {
107 self.db
108 .cf_handle(CF_PROJECTS)
109 .expect("projects CF must exist")
110 }
111 fn todo_index_cf(&self) -> &ColumnFamily {
112 self.db
113 .cf_handle(CF_TODO_INDEX)
114 .expect("todo_index CF must exist")
115 }
116
117 pub fn new(db: Arc<DB>, storage_path: &Path) -> Result<Self> {
119 let todos_path = storage_path.join("todos");
120 std::fs::create_dir_all(&todos_path)?;
121
122 Self::migrate_from_separate_dbs(&todos_path, &db)?;
124
125 let index_cf = db
127 .cf_handle(CF_TODO_INDEX)
128 .expect("todo_index CF must exist");
129 migrate_due_key_padding(&db, index_cf)?;
130
131 tracing::info!("Todo store initialized");
132
133 Ok(Self {
134 db,
135 vector_indices: RwLock::new(HashMap::new()),
136 storage_path: todos_path,
137 seq_mutex: parking_lot::Mutex::new(()),
138 })
139 }
140
141 fn migrate_from_separate_dbs(todos_path: &Path, db: &DB) -> Result<()> {
145 let old_dirs: &[(&str, &str)] = &[
146 ("items", CF_TODOS),
147 ("projects", CF_PROJECTS),
148 ("index", CF_TODO_INDEX),
149 ];
150
151 for (old_name, cf_name) in old_dirs {
152 let old_dir = todos_path.join(old_name);
153 if !old_dir.is_dir() {
154 continue;
155 }
156
157 let cf = db
158 .cf_handle(cf_name)
159 .unwrap_or_else(|| panic!("{cf_name} CF must exist"));
160 let old_opts = Options::default();
161 match DB::open_for_read_only(&old_opts, &old_dir, false) {
162 Ok(old_db) => {
163 let mut batch = WriteBatch::default();
164 let mut count = 0usize;
165 for (key, value) in old_db.iterator(rocksdb::IteratorMode::Start).flatten() {
166 batch.put_cf(cf, &key, &value);
167 count += 1;
168 if count % 10_000 == 0 {
169 db.write(std::mem::take(&mut batch))?;
170 }
171 }
172 if !batch.is_empty() {
173 db.write(batch)?;
174 }
175 drop(old_db);
176 tracing::info!(" todos/{old_name}: migrated {count} entries to {cf_name} CF");
177
178 let backup = todos_path.join(format!("{old_name}.pre_cf_migration"));
179 if backup.exists() {
180 let _ = std::fs::remove_dir_all(&backup);
181 }
182 if let Err(e) = std::fs::rename(&old_dir, &backup) {
183 tracing::warn!("Could not rename old {old_name} dir: {e}");
184 }
185 }
186 Err(e) => {
187 tracing::warn!("Could not open old {old_name} DB for migration: {e}");
188 }
189 }
190 }
191
192 Ok(())
193 }
194
195 fn get_or_create_index(&self, user_id: &str) -> Result<()> {
197 let mut indices = self.vector_indices.write();
198 if !indices.contains_key(user_id) {
199 let config = VamanaConfig {
200 dimension: EMBEDDING_DIM,
201 max_degree: 32,
202 search_list_size: 75,
203 alpha: 1.2,
204 ..Default::default()
205 };
206 let index = VamanaIndex::new(config)?;
207 indices.insert(user_id.to_string(), index);
208 }
209 Ok(())
210 }
211
212 pub fn index_todo_embedding(
215 &self,
216 user_id: &str,
217 _todo_id: &TodoId,
218 embedding: &[f32],
219 ) -> Result<u32> {
220 self.get_or_create_index(user_id)?;
221
222 let mut indices = self.vector_indices.write();
223 if let Some(index) = indices.get_mut(user_id) {
224 let vector_id = index.add_vector(embedding.to_vec())?;
226 return Ok(vector_id);
227 }
228 anyhow::bail!("Failed to get vector index for user: {}", user_id)
229 }
230
231 pub fn search_similar(
233 &self,
234 user_id: &str,
235 query_embedding: &[f32],
236 limit: usize,
237 ) -> Result<Vec<(Todo, f32)>> {
238 let indices = self.vector_indices.read();
239 if let Some(index) = indices.get(user_id) {
240 let results = index.search(query_embedding, limit)?;
241
242 let mut todo_results = Vec::new();
244 for (vector_id, score) in results {
245 if let Some(todo) = self.get_todo_by_vector_id(user_id, vector_id)? {
246 todo_results.push((todo, score));
247 }
248 }
249 Ok(todo_results)
250 } else {
251 Ok(Vec::new())
252 }
253 }
254
255 fn get_todo_by_vector_id(&self, user_id: &str, vector_id: u32) -> Result<Option<Todo>> {
257 let key = format!("vector_id:{}:{}", user_id, vector_id);
258 if let Some(data) = self.db.get_cf(self.todo_index_cf(), key.as_bytes())? {
259 let todo_id_str = String::from_utf8_lossy(&data);
260 if let Ok(uuid) = Uuid::parse_str(&todo_id_str) {
261 return self.get_todo(user_id, &TodoId(uuid));
262 }
263 }
264 Ok(None)
265 }
266
267 pub fn store_vector_id_mapping(
269 &self,
270 user_id: &str,
271 vector_id: u32,
272 todo_id: &TodoId,
273 ) -> Result<()> {
274 let mut batch = WriteBatch::default();
275 let index_cf = self.todo_index_cf();
276
277 let fwd_key = format!("vector_id:{}:{}", user_id, vector_id);
279 batch.put_cf(
280 index_cf,
281 fwd_key.as_bytes(),
282 todo_id.0.to_string().as_bytes(),
283 );
284
285 let rev_key = format!("todo_vector:{}:{}", user_id, todo_id.0);
287 batch.put_cf(index_cf, rev_key.as_bytes(), vector_id.to_le_bytes());
288
289 self.db.write(batch)?;
290 Ok(())
291 }
292
293 pub fn save_vector_indices(&self) -> Result<()> {
295 let indices = self.vector_indices.read();
296 for (user_id, index) in indices.iter() {
297 let index_path = self.storage_path.join("vectors").join(user_id);
298 std::fs::create_dir_all(&index_path)?;
299 index.save(&index_path)?;
300 }
301 Ok(())
302 }
303
304 pub fn load_vector_indices(&self) -> Result<()> {
306 let vectors_path = self.storage_path.join("vectors");
307 if !vectors_path.exists() {
308 return Ok(());
309 }
310
311 let mut indices = self.vector_indices.write();
312 for entry in std::fs::read_dir(&vectors_path)? {
313 let entry = entry?;
314 if entry.file_type()?.is_dir() {
315 let user_id = entry.file_name().to_string_lossy().to_string();
316 let index_path = entry.path();
317
318 let config = VamanaConfig {
320 dimension: EMBEDDING_DIM,
321 ..Default::default()
322 };
323 let mut index = VamanaIndex::new(config)?;
324 if index.load(&index_path).is_ok() {
325 indices.insert(user_id.clone(), index);
326 tracing::debug!("Loaded todo vector index for user: {}", user_id);
327 }
328 }
329 }
330 Ok(())
331 }
332
333 fn next_seq_num(&self, user_id: &str, project_id: Option<&ProjectId>) -> Result<u32> {
340 let _lock = self.seq_mutex.lock();
342 let key = match project_id {
343 Some(pid) => format!("seq:{}:{}", user_id, pid.0),
344 None => format!("seq:{}:_standalone_", user_id),
345 };
346 let current = match self.db.get_cf(self.todo_index_cf(), key.as_bytes())? {
347 Some(data) => {
348 if data.len() >= 4 {
349 let bytes: [u8; 4] = [data[0], data[1], data[2], data[3]];
350 u32::from_le_bytes(bytes)
351 } else {
352 0
353 }
354 }
355 None => 0,
356 };
357 let next = current + 1;
358 self.db
359 .put_cf(self.todo_index_cf(), key.as_bytes(), next.to_le_bytes())?;
360 Ok(next)
361 }
362
363 pub fn assign_seq_num(&self, todo: &mut Todo) -> Result<()> {
365 if todo.seq_num == 0 {
366 if let Some(ref project_id) = todo.project_id {
368 if let Some(project) = self.get_project(&todo.user_id, project_id)? {
369 todo.project_prefix = Some(project.effective_prefix());
370 }
371 }
372 todo.seq_num = self.next_seq_num(&todo.user_id, todo.project_id.as_ref())?;
373 todo.sync_compat_fields();
374 }
375 Ok(())
376 }
377
378 pub fn store_todo(&self, todo: &Todo) -> Result<Todo> {
384 let mut todo_to_store = todo.clone();
386 if todo_to_store.seq_num == 0 {
387 if let Some(ref project_id) = todo_to_store.project_id {
389 if todo_to_store.project_prefix.is_none() {
390 if let Some(project) = self.get_project(&todo_to_store.user_id, project_id)? {
391 todo_to_store.project_prefix = Some(project.effective_prefix());
392 }
393 }
394 }
395 todo_to_store.seq_num =
396 self.next_seq_num(&todo_to_store.user_id, todo_to_store.project_id.as_ref())?;
397 }
398 todo_to_store.sync_compat_fields();
399
400 let key = format!("{}:{}", todo_to_store.user_id, todo_to_store.id.0);
401 let value = serde_json::to_vec(&todo_to_store).context("Failed to serialize todo")?;
402
403 self.db
404 .put_cf(self.todos_cf(), key.as_bytes(), &value)
405 .context("Failed to store todo")?;
406
407 self.update_todo_indices(&todo_to_store)?;
408
409 tracing::debug!(
410 todo_id = %todo_to_store.id,
411 short_id = %todo_to_store.short_id(),
412 user_id = %todo_to_store.user_id,
413 status = ?todo_to_store.status,
414 "Stored todo"
415 );
416
417 Ok(todo_to_store)
418 }
419
420 fn update_todo_indices(&self, todo: &Todo) -> Result<()> {
422 let mut batch = WriteBatch::default();
423 let id_str = todo.id.0.to_string();
424 let index_cf = self.todo_index_cf();
425
426 let user_key = format!("user:{}:{}", todo.user_id, id_str);
428 batch.put_cf(index_cf, user_key.as_bytes(), b"1");
429
430 let status_key = format!("status:{:?}:{}:{}", todo.status, todo.user_id, id_str);
432 batch.put_cf(index_cf, status_key.as_bytes(), b"1");
433
434 let priority_key = format!(
436 "priority:{}:{}:{}",
437 todo.priority.value(),
438 todo.user_id,
439 id_str
440 );
441 batch.put_cf(index_cf, priority_key.as_bytes(), b"1");
442
443 if let Some(ref project_id) = todo.project_id {
445 let project_key = format!("project:{}:{}:{}", project_id.0, todo.user_id, id_str);
446 batch.put_cf(index_cf, project_key.as_bytes(), b"1");
447 }
448
449 if let Some(ref due) = todo.due_date {
451 let due_key = format!("due:{:020}:{}:{}", due.timestamp(), todo.user_id, id_str);
452 batch.put_cf(index_cf, due_key.as_bytes(), b"1");
453 }
454
455 for ctx in &todo.contexts {
457 let ctx_key = format!("context:{}:{}:{}", ctx.to_lowercase(), todo.user_id, id_str);
458 batch.put_cf(index_cf, ctx_key.as_bytes(), b"1");
459 }
460
461 if let Some(ref parent_id) = todo.parent_id {
463 let parent_key = format!("parent:{}:{}", parent_id.0, id_str);
464 batch.put_cf(index_cf, parent_key.as_bytes(), todo.user_id.as_bytes());
465 }
466
467 self.db
468 .write(batch)
469 .context("Failed to update todo indices")?;
470
471 Ok(())
472 }
473
474 fn remove_todo_indices(&self, todo: &Todo) -> Result<()> {
476 let mut batch = WriteBatch::default();
477 let id_str = todo.id.0.to_string();
478 let index_cf = self.todo_index_cf();
479
480 let user_key = format!("user:{}:{}", todo.user_id, id_str);
481 batch.delete_cf(index_cf, user_key.as_bytes());
482
483 let status_key = format!("status:{:?}:{}:{}", todo.status, todo.user_id, id_str);
484 batch.delete_cf(index_cf, status_key.as_bytes());
485
486 let priority_key = format!(
487 "priority:{}:{}:{}",
488 todo.priority.value(),
489 todo.user_id,
490 id_str
491 );
492 batch.delete_cf(index_cf, priority_key.as_bytes());
493
494 if let Some(ref project_id) = todo.project_id {
495 let project_key = format!("project:{}:{}:{}", project_id.0, todo.user_id, id_str);
496 batch.delete_cf(index_cf, project_key.as_bytes());
497 }
498
499 if let Some(ref due) = todo.due_date {
500 let due_key = format!("due:{:020}:{}:{}", due.timestamp(), todo.user_id, id_str);
501 batch.delete_cf(index_cf, due_key.as_bytes());
502 }
503
504 for ctx in &todo.contexts {
505 let ctx_key = format!("context:{}:{}:{}", ctx.to_lowercase(), todo.user_id, id_str);
506 batch.delete_cf(index_cf, ctx_key.as_bytes());
507 }
508
509 if let Some(ref parent_id) = todo.parent_id {
510 let parent_key = format!("parent:{}:{}", parent_id.0, id_str);
511 batch.delete_cf(index_cf, parent_key.as_bytes());
512 }
513
514 let rev_key = format!("todo_vector:{}:{}", todo.user_id, id_str);
516 if let Some(vid_bytes) = self.db.get_cf(index_cf, rev_key.as_bytes())? {
517 if vid_bytes.len() >= 4 {
518 let vector_id =
519 u32::from_le_bytes([vid_bytes[0], vid_bytes[1], vid_bytes[2], vid_bytes[3]]);
520
521 let indices = self.vector_indices.read();
523 if let Some(index) = indices.get(&todo.user_id) {
524 index.mark_deleted(vector_id);
525 }
526
527 let fwd_key = format!("vector_id:{}:{}", todo.user_id, vector_id);
529 batch.delete_cf(index_cf, fwd_key.as_bytes());
530 }
531 batch.delete_cf(index_cf, rev_key.as_bytes());
533 }
534
535 self.db.write(batch)?;
536 Ok(())
537 }
538
539 pub fn get_todo(&self, user_id: &str, todo_id: &TodoId) -> Result<Option<Todo>> {
541 let key = format!("{}:{}", user_id, todo_id.0);
542
543 match self.db.get_cf(self.todos_cf(), key.as_bytes())? {
544 Some(value) => {
545 let mut todo: Todo =
546 serde_json::from_slice(&value).context("Failed to deserialize todo")?;
547 todo.sync_compat_fields();
548 Ok(Some(todo))
549 }
550 None => Ok(None),
551 }
552 }
553
554 pub fn find_todo_by_prefix(&self, user_id: &str, prefix: &str) -> Result<Option<Todo>> {
556 let todos = self.list_todos_for_user(user_id, None)?;
557
558 let prefix_upper = prefix.trim().to_uppercase();
560
561 if let Some((project_prefix, seq_str)) = prefix_upper.rsplit_once('-') {
563 if let Ok(seq_num) = seq_str.parse::<u32>() {
565 if let Some(todo) = todos.iter().find(|t| {
567 t.seq_num == seq_num
568 && t.project_prefix
569 .as_ref()
570 .map(|p| p.to_uppercase() == project_prefix)
571 .unwrap_or(project_prefix == "SHO")
572 }) {
573 return Ok(Some(todo.clone()));
574 }
575 }
576 }
577
578 if let Ok(seq_num) = prefix_upper.parse::<u32>() {
580 if let Some(todo) = todos.iter().find(|t| t.seq_num == seq_num) {
582 return Ok(Some(todo.clone()));
583 }
584 }
585
586 let clean_prefix_lower = prefix.to_lowercase();
588 let matches: Vec<_> = todos
589 .into_iter()
590 .filter(|t| {
591 t.id.0
592 .to_string()
593 .to_lowercase()
594 .starts_with(&clean_prefix_lower)
595 })
596 .collect();
597
598 match matches.len() {
599 0 => Ok(None),
600 1 => Ok(Some(matches.into_iter().next().unwrap())),
601 _ => {
602 tracing::warn!(
603 user_id = %user_id,
604 prefix = %prefix,
605 matches = matches.len(),
606 "Multiple todos match prefix, using first"
607 );
608 Ok(Some(matches.into_iter().next().unwrap()))
609 }
610 }
611 }
612
613 pub fn find_by_external_id(&self, user_id: &str, external_id: &str) -> Result<Option<Todo>> {
616 let todos = self.list_todos_for_user(user_id, None)?;
617 Ok(todos
618 .into_iter()
619 .find(|t| t.external_id.as_deref() == Some(external_id)))
620 }
621
622 pub fn update_todo(&self, todo: &Todo) -> Result<()> {
624 if let Some(old_todo) = self.get_todo(&todo.user_id, &todo.id)? {
626 self.remove_todo_indices(&old_todo)?;
627 }
628
629 self.store_todo(todo).map(|_| ())
630 }
631
632 pub fn delete_todo(&self, user_id: &str, todo_id: &TodoId) -> Result<bool> {
634 let key = format!("{}:{}", user_id, todo_id.0);
635
636 if let Some(todo) = self.get_todo(user_id, todo_id)? {
637 let subtasks = self.list_subtasks(todo_id)?;
639 for subtask in &subtasks {
640 self.remove_todo_indices(subtask)?;
641 let subtask_key = format!("{}:{}", subtask.user_id, subtask.id.0);
642 self.db.delete_cf(self.todos_cf(), subtask_key.as_bytes())?;
643 tracing::debug!(
644 todo_id = %subtask.id,
645 parent_id = %todo_id,
646 "Cascade deleted subtask"
647 );
648 }
649
650 self.remove_todo_indices(&todo)?;
651 self.db.delete_cf(self.todos_cf(), key.as_bytes())?;
652 tracing::debug!(
653 todo_id = %todo_id,
654 subtasks_deleted = subtasks.len(),
655 "Deleted todo"
656 );
657 Ok(true)
658 } else {
659 Ok(false)
660 }
661 }
662
663 pub fn complete_todo(
665 &self,
666 user_id: &str,
667 todo_id: &TodoId,
668 ) -> Result<Option<(Todo, Option<Todo>)>> {
669 if let Some(mut todo) = self.get_todo(user_id, todo_id)? {
670 self.remove_todo_indices(&todo)?;
672
673 todo.complete();
675
676 let stored_todo = self.store_todo(&todo)?;
678
679 let next_todo = if let Some(next) = stored_todo.create_next_recurrence() {
681 Some(self.store_todo(&next)?)
682 } else {
683 None
684 };
685
686 Ok(Some((stored_todo, next_todo)))
687 } else {
688 Ok(None)
689 }
690 }
691
692 pub fn add_comment(
698 &self,
699 user_id: &str,
700 todo_id: &TodoId,
701 author: String,
702 content: String,
703 comment_type: Option<TodoCommentType>,
704 ) -> Result<Option<TodoComment>> {
705 if let Some(mut todo) = self.get_todo(user_id, todo_id)? {
706 let mut comment = TodoComment::new(todo_id.clone(), author, content);
707 if let Some(ct) = comment_type {
708 comment.comment_type = ct;
709 }
710 let comment_clone = comment.clone();
711 todo.comments.push(comment);
712 self.update_todo(&todo)?;
713
714 tracing::debug!(
715 todo_id = %todo_id,
716 comment_id = %comment_clone.id.0,
717 "Added comment to todo"
718 );
719
720 Ok(Some(comment_clone))
721 } else {
722 Ok(None)
723 }
724 }
725
726 pub fn add_activity(&self, user_id: &str, todo_id: &TodoId, content: String) -> Result<bool> {
728 if let Some(mut todo) = self.get_todo(user_id, todo_id)? {
729 todo.add_activity(content);
730 self.update_todo(&todo)?;
731 Ok(true)
732 } else {
733 Ok(false)
734 }
735 }
736
737 pub fn update_comment(
739 &self,
740 user_id: &str,
741 todo_id: &TodoId,
742 comment_id: &TodoCommentId,
743 content: String,
744 ) -> Result<Option<TodoComment>> {
745 if let Some(mut todo) = self.get_todo(user_id, todo_id)? {
746 if let Some(comment) = todo.comments.iter_mut().find(|c| c.id == *comment_id) {
747 comment.content = content;
748 comment.updated_at = Some(chrono::Utc::now());
749 let comment_clone = comment.clone();
750 self.update_todo(&todo)?;
751 Ok(Some(comment_clone))
752 } else {
753 Ok(None)
754 }
755 } else {
756 Ok(None)
757 }
758 }
759
760 pub fn delete_comment(
762 &self,
763 user_id: &str,
764 todo_id: &TodoId,
765 comment_id: &TodoCommentId,
766 ) -> Result<bool> {
767 if let Some(mut todo) = self.get_todo(user_id, todo_id)? {
768 let initial_len = todo.comments.len();
769 todo.comments.retain(|c| c.id != *comment_id);
770 if todo.comments.len() < initial_len {
771 self.update_todo(&todo)?;
772 Ok(true)
773 } else {
774 Ok(false)
775 }
776 } else {
777 Ok(false)
778 }
779 }
780
781 pub fn get_comments(&self, user_id: &str, todo_id: &TodoId) -> Result<Vec<TodoComment>> {
783 if let Some(todo) = self.get_todo(user_id, todo_id)? {
784 Ok(todo.comments)
785 } else {
786 Ok(Vec::new())
787 }
788 }
789
790 pub fn reorder_todo(
793 &self,
794 user_id: &str,
795 todo_id: &TodoId,
796 direction: &str,
797 ) -> Result<Option<Todo>> {
798 let todo = match self.get_todo(user_id, todo_id)? {
799 Some(t) => t,
800 None => return Ok(None),
801 };
802
803 let mut same_status_todos: Vec<Todo> = self
805 .list_todos_for_user(user_id, Some(&[todo.status.clone()]))?
806 .into_iter()
807 .collect();
808
809 same_status_todos.sort_by_key(|t| t.sort_order);
811
812 let pos = same_status_todos
814 .iter()
815 .position(|t| t.id == *todo_id)
816 .unwrap_or(0);
817
818 let swap_pos = match direction {
819 "up" => {
820 if pos == 0 {
821 return Ok(Some(todo)); }
823 pos - 1
824 }
825 "down" => {
826 if pos >= same_status_todos.len() - 1 {
827 return Ok(Some(todo)); }
829 pos + 1
830 }
831 _ => return Ok(Some(todo)), };
833
834 let mut current = same_status_todos[pos].clone();
836 let mut adjacent = same_status_todos[swap_pos].clone();
837
838 std::mem::swap(&mut current.sort_order, &mut adjacent.sort_order);
839
840 current.updated_at = Utc::now();
842 adjacent.updated_at = Utc::now();
843
844 self.update_todo(¤t)?;
845 self.update_todo(&adjacent)?;
846
847 Ok(Some(current))
848 }
849
850 pub fn list_todos_for_user(
856 &self,
857 user_id: &str,
858 status_filter: Option<&[TodoStatus]>,
859 ) -> Result<Vec<Todo>> {
860 let prefix = format!("user:{}:", user_id);
861 let mut todos = Vec::new();
862
863 let iter = self
864 .db
865 .prefix_iterator_cf(self.todo_index_cf(), prefix.as_bytes());
866
867 for item in iter {
868 let (key, _) = item?;
869 let key_str = String::from_utf8_lossy(&key);
870
871 if !key_str.starts_with(&prefix) {
872 break;
873 }
874
875 let todo_id_str = key_str.strip_prefix(&prefix).unwrap_or("");
877 if let Ok(uuid) = Uuid::parse_str(todo_id_str) {
878 let todo_id = TodoId(uuid);
879 if let Some(todo) = self.get_todo(user_id, &todo_id)? {
880 if let Some(statuses) = status_filter {
882 if statuses.contains(&todo.status) {
883 todos.push(todo);
884 }
885 } else {
886 todos.push(todo);
887 }
888 }
889 }
890 }
891
892 todos.sort_by(|a, b| {
894 let order_cmp = a.sort_order.cmp(&b.sort_order);
896 if order_cmp != std::cmp::Ordering::Equal {
897 return order_cmp;
898 }
899 let priority_cmp = a.priority.value().cmp(&b.priority.value());
901 if priority_cmp != std::cmp::Ordering::Equal {
902 return priority_cmp;
903 }
904 match (&a.due_date, &b.due_date) {
906 (Some(a_due), Some(b_due)) => a_due.cmp(b_due),
907 (Some(_), None) => std::cmp::Ordering::Less,
908 (None, Some(_)) => std::cmp::Ordering::Greater,
909 (None, None) => std::cmp::Ordering::Equal,
910 }
911 });
912
913 Ok(todos)
914 }
915
916 pub fn list_todos_by_project(
918 &self,
919 user_id: &str,
920 project_id: &ProjectId,
921 ) -> Result<Vec<Todo>> {
922 let prefix = format!("project:{}:{}:", project_id.0, user_id);
923 let mut todos = Vec::new();
924
925 let iter = self
926 .db
927 .prefix_iterator_cf(self.todo_index_cf(), prefix.as_bytes());
928
929 for item in iter {
930 let (key, _) = item?;
931 let key_str = String::from_utf8_lossy(&key);
932
933 if !key_str.starts_with(&prefix) {
934 break;
935 }
936
937 let todo_id_str = key_str.strip_prefix(&prefix).unwrap_or("");
938 if let Ok(uuid) = Uuid::parse_str(todo_id_str) {
939 if let Some(todo) = self.get_todo(user_id, &TodoId(uuid))? {
940 todos.push(todo);
941 }
942 }
943 }
944
945 Ok(todos)
946 }
947
948 pub fn list_todos_by_context(&self, user_id: &str, context: &str) -> Result<Vec<Todo>> {
950 let ctx_lower = context.to_lowercase();
951 let prefix = format!("context:{}:{}:", ctx_lower, user_id);
952 let mut todos = Vec::new();
953
954 let iter = self
955 .db
956 .prefix_iterator_cf(self.todo_index_cf(), prefix.as_bytes());
957
958 for item in iter {
959 let (key, _) = item?;
960 let key_str = String::from_utf8_lossy(&key);
961
962 if !key_str.starts_with(&prefix) {
963 break;
964 }
965
966 let todo_id_str = key_str.strip_prefix(&prefix).unwrap_or("");
967 if let Ok(uuid) = Uuid::parse_str(todo_id_str) {
968 if let Some(todo) = self.get_todo(user_id, &TodoId(uuid))? {
969 todos.push(todo);
970 }
971 }
972 }
973
974 Ok(todos)
975 }
976
977 pub fn list_due_todos(&self, user_id: &str, include_overdue: bool) -> Result<Vec<Todo>> {
979 let now = Utc::now();
980 let end_of_today = now
981 .date_naive()
982 .and_hms_opt(23, 59, 59)
983 .map(|t| t.and_utc())
984 .unwrap_or(now);
985
986 let todos = self.list_todos_for_user(user_id, None)?;
987
988 let due_todos: Vec<_> = todos
989 .into_iter()
990 .filter(|t| {
991 if t.status == TodoStatus::Done || t.status == TodoStatus::Cancelled {
992 return false;
993 }
994 if let Some(due) = &t.due_date {
995 if include_overdue && *due < now {
996 return true;
997 }
998 *due <= end_of_today
999 } else {
1000 false
1001 }
1002 })
1003 .collect();
1004
1005 Ok(due_todos)
1006 }
1007
1008 pub fn list_subtasks(&self, parent_id: &TodoId) -> Result<Vec<Todo>> {
1010 let prefix = format!("parent:{}:", parent_id.0);
1011 let mut todos = Vec::new();
1012
1013 let iter = self
1014 .db
1015 .prefix_iterator_cf(self.todo_index_cf(), prefix.as_bytes());
1016
1017 for item in iter {
1018 let (key, value) = item?;
1019 let key_str = String::from_utf8_lossy(&key);
1020
1021 if !key_str.starts_with(&prefix) {
1022 break;
1023 }
1024
1025 let todo_id_str = key_str.strip_prefix(&prefix).unwrap_or("");
1026 let user_id = String::from_utf8_lossy(&value);
1027
1028 if let Ok(uuid) = Uuid::parse_str(todo_id_str) {
1029 if let Some(todo) = self.get_todo(&user_id, &TodoId(uuid))? {
1030 todos.push(todo);
1031 }
1032 }
1033 }
1034
1035 Ok(todos)
1036 }
1037
1038 pub fn store_project(&self, project: &Project) -> Result<()> {
1044 let key = format!("{}:{}", project.user_id, project.id.0);
1045 let value = serde_json::to_vec(project).context("Failed to serialize project")?;
1046
1047 self.db.put_cf(self.projects_cf(), key.as_bytes(), &value)?;
1048
1049 let user_key = format!("user:{}:{}", project.user_id, project.id.0);
1051 self.db
1052 .put_cf(self.todo_index_cf(), user_key.as_bytes(), b"p")?; let name_key = format!(
1056 "project_name:{}:{}",
1057 project.name.to_lowercase(),
1058 project.user_id
1059 );
1060 self.db.put_cf(
1061 self.todo_index_cf(),
1062 name_key.as_bytes(),
1063 project.id.0.to_string().as_bytes(),
1064 )?;
1065
1066 if let Some(ref parent_id) = project.parent_id {
1068 let parent_key = format!(
1069 "project_parent:{}:{}:{}",
1070 project.user_id, parent_id.0, project.id.0
1071 );
1072 self.db
1073 .put_cf(self.todo_index_cf(), parent_key.as_bytes(), b"1")?;
1074 }
1075
1076 tracing::debug!(project_id = %project.id.0, name = %project.name, parent = ?project.parent_id, "Stored project");
1077
1078 Ok(())
1079 }
1080
1081 pub fn get_project(&self, user_id: &str, project_id: &ProjectId) -> Result<Option<Project>> {
1083 let key = format!("{}:{}", user_id, project_id.0);
1084
1085 match self.db.get_cf(self.projects_cf(), key.as_bytes())? {
1086 Some(value) => {
1087 let project: Project =
1088 serde_json::from_slice(&value).context("Failed to deserialize project")?;
1089 Ok(Some(project))
1090 }
1091 None => Ok(None),
1092 }
1093 }
1094
1095 pub fn find_project_by_name(&self, user_id: &str, name: &str) -> Result<Option<Project>> {
1097 let name_key = format!("project_name:{}:{}", name.to_lowercase(), user_id);
1098
1099 if let Some(value) = self.db.get_cf(self.todo_index_cf(), name_key.as_bytes())? {
1100 if let Ok(uuid) = Uuid::parse_str(&String::from_utf8_lossy(&value)) {
1101 return self.get_project(user_id, &ProjectId(uuid));
1102 }
1103 }
1104
1105 Ok(None)
1106 }
1107
1108 pub fn find_or_create_project(&self, user_id: &str, name: &str) -> Result<Project> {
1110 if let Some(project) = self.find_project_by_name(user_id, name)? {
1111 return Ok(project);
1112 }
1113
1114 let project = Project::new(user_id.to_string(), name.to_string());
1115 self.store_project(&project)?;
1116 Ok(project)
1117 }
1118
1119 pub fn list_projects(&self, user_id: &str) -> Result<Vec<Project>> {
1121 let mut projects = Vec::new();
1122
1123 let iter = self
1124 .db
1125 .prefix_iterator_cf(self.projects_cf(), format!("{}:", user_id).as_bytes());
1126
1127 for item in iter {
1128 let (key, value) = item?;
1129 let key_str = String::from_utf8_lossy(&key);
1130
1131 if !key_str.starts_with(&format!("{}:", user_id)) {
1132 break;
1133 }
1134
1135 let project: Project = serde_json::from_slice(&value)?;
1136 projects.push(project);
1137 }
1138
1139 projects.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
1141
1142 Ok(projects)
1143 }
1144
1145 pub fn list_subprojects(&self, user_id: &str, parent_id: &ProjectId) -> Result<Vec<Project>> {
1147 let mut subprojects = Vec::new();
1148
1149 let prefix = format!("project_parent:{}:{}:", user_id, parent_id.0);
1150 let iter = self
1151 .db
1152 .prefix_iterator_cf(self.todo_index_cf(), prefix.as_bytes());
1153
1154 for item in iter {
1155 let (key, _) = item?;
1156 let key_str = String::from_utf8_lossy(&key);
1157
1158 if !key_str.starts_with(&prefix) {
1159 break;
1160 }
1161
1162 let parts: Vec<&str> = key_str.split(':').collect();
1164 if parts.len() >= 4 {
1165 if let Ok(uuid) = Uuid::parse_str(parts[3]) {
1166 if let Some(project) = self.get_project(user_id, &ProjectId(uuid))? {
1167 subprojects.push(project);
1168 }
1169 }
1170 }
1171 }
1172
1173 subprojects.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
1175
1176 Ok(subprojects)
1177 }
1178
1179 pub fn get_project_stats(&self, user_id: &str, project_id: &ProjectId) -> Result<ProjectStats> {
1181 let todos = self.list_todos_by_project(user_id, project_id)?;
1182
1183 let mut stats = ProjectStats::default();
1184 for todo in &todos {
1185 match todo.status {
1186 TodoStatus::Backlog => stats.backlog += 1,
1187 TodoStatus::Todo => stats.todo += 1,
1188 TodoStatus::InProgress => stats.in_progress += 1,
1189 TodoStatus::Blocked => stats.blocked += 1,
1190 TodoStatus::Done => stats.done += 1,
1191 TodoStatus::Cancelled => stats.cancelled += 1,
1192 }
1193 }
1194 stats.total = todos.len();
1195
1196 Ok(stats)
1197 }
1198
1199 pub fn update_project(
1201 &self,
1202 user_id: &str,
1203 project_id: &ProjectId,
1204 name: Option<String>,
1205 prefix: Option<String>,
1206 description: Option<Option<String>>,
1207 status: Option<ProjectStatus>,
1208 color: Option<Option<String>>,
1209 ) -> Result<Option<Project>> {
1210 if let Some(mut project) = self.get_project(user_id, project_id)? {
1211 let old_name = project.name.clone();
1212 let mut changed = false;
1213
1214 if let Some(new_name) = name {
1215 if !new_name.trim().is_empty() && new_name != project.name {
1216 project.name = new_name;
1217 changed = true;
1218 }
1219 }
1220
1221 if let Some(new_prefix) = prefix {
1222 let clean = new_prefix.trim().to_uppercase();
1223 if !clean.is_empty() {
1224 project.prefix = Some(clean);
1225 changed = true;
1226 }
1227 }
1228
1229 if let Some(new_description) = description {
1230 project.description = new_description;
1231 changed = true;
1232 }
1233
1234 if let Some(new_status) = status {
1235 if new_status != project.status {
1236 project.status = new_status.clone();
1237 changed = true;
1238
1239 if new_status == ProjectStatus::Completed
1241 || new_status == ProjectStatus::Archived
1242 {
1243 project.completed_at = Some(Utc::now());
1244 } else {
1245 project.completed_at = None;
1246 }
1247 }
1248 }
1249
1250 if let Some(new_color) = color {
1251 project.color = new_color;
1252 changed = true;
1253 }
1254
1255 if changed {
1256 if project.name != old_name {
1258 let old_name_key =
1259 format!("project_name:{}:{}", old_name.to_lowercase(), user_id);
1260 self.db
1261 .delete_cf(self.todo_index_cf(), old_name_key.as_bytes())?;
1262 }
1263
1264 self.store_project(&project)?;
1265 }
1266
1267 Ok(Some(project))
1268 } else {
1269 Ok(None)
1270 }
1271 }
1272
1273 pub fn delete_project(
1275 &self,
1276 user_id: &str,
1277 project_id: &ProjectId,
1278 delete_todos: bool,
1279 ) -> Result<bool> {
1280 if let Some(project) = self.get_project(user_id, project_id)? {
1281 if delete_todos {
1283 let todos = self.list_todos_by_project(user_id, project_id)?;
1284 for todo in todos {
1285 self.delete_todo(user_id, &todo.id)?;
1286 }
1287 }
1288
1289 let subprojects = self.list_subprojects(user_id, project_id)?;
1291 for subproject in subprojects {
1292 self.delete_project(user_id, &subproject.id, delete_todos)?;
1293 }
1294
1295 let key = format!("{}:{}", user_id, project_id.0);
1297 self.db.delete_cf(self.projects_cf(), key.as_bytes())?;
1298
1299 let user_key = format!("user:{}:{}", user_id, project_id.0);
1301 self.db
1302 .delete_cf(self.todo_index_cf(), user_key.as_bytes())?;
1303
1304 let name_key = format!("project_name:{}:{}", project.name.to_lowercase(), user_id);
1305 self.db
1306 .delete_cf(self.todo_index_cf(), name_key.as_bytes())?;
1307
1308 if let Some(ref parent_id) = project.parent_id {
1310 let parent_key = format!(
1311 "project_parent:{}:{}:{}",
1312 user_id, parent_id.0, project_id.0
1313 );
1314 self.db
1315 .delete_cf(self.todo_index_cf(), parent_key.as_bytes())?;
1316 }
1317
1318 Ok(true)
1319 } else {
1320 Ok(false)
1321 }
1322 }
1323
1324 pub fn purge_user_vectors(&self, user_id: &str) {
1326 let mut indices = self.vector_indices.write();
1327 indices.remove(user_id);
1328 }
1329
1330 pub fn flush(&self) -> Result<()> {
1336 use rocksdb::FlushOptions;
1337 let mut flush_opts = FlushOptions::default();
1338 flush_opts.set_wait(true);
1339
1340 for cf_name in &[CF_TODOS, CF_PROJECTS, CF_TODO_INDEX] {
1341 if let Some(cf) = self.db.cf_handle(cf_name) {
1342 self.db
1343 .flush_cf_opt(cf, &flush_opts)
1344 .map_err(|e| anyhow::anyhow!("Failed to flush {cf_name}: {e}"))?;
1345 }
1346 }
1347
1348 self.save_vector_indices()
1350 .map_err(|e| anyhow::anyhow!("Failed to save todo vector indices: {e}"))?;
1351
1352 Ok(())
1353 }
1354
1355 pub fn databases(&self) -> Vec<(&str, &Arc<DB>)> {
1357 vec![("todos_shared", &self.db)]
1358 }
1359
1360 pub fn get_user_stats(&self, user_id: &str) -> Result<UserTodoStats> {
1362 let todos = self.list_todos_for_user(user_id, None)?;
1363
1364 let mut stats = UserTodoStats::default();
1365
1366 for todo in &todos {
1367 stats.total += 1;
1368 match todo.status {
1369 TodoStatus::Backlog => stats.backlog += 1,
1370 TodoStatus::Todo => stats.todo += 1,
1371 TodoStatus::InProgress => stats.in_progress += 1,
1372 TodoStatus::Blocked => stats.blocked += 1,
1373 TodoStatus::Done => stats.done += 1,
1374 TodoStatus::Cancelled => stats.cancelled += 1,
1375 }
1376
1377 if todo.is_overdue() {
1378 stats.overdue += 1;
1379 }
1380 if todo.is_due_today() {
1381 stats.due_today += 1;
1382 }
1383 }
1384
1385 stats.projects = self.list_projects(user_id)?.len();
1386
1387 Ok(stats)
1388 }
1389}
1390
1391#[derive(Debug, Clone, Default, serde::Serialize)]
1393pub struct ProjectStats {
1394 pub total: usize,
1395 pub backlog: usize,
1396 pub todo: usize,
1397 pub in_progress: usize,
1398 pub blocked: usize,
1399 pub done: usize,
1400 pub cancelled: usize,
1401}
1402
1403#[derive(Debug, Clone, Default, serde::Serialize)]
1405pub struct UserTodoStats {
1406 pub total: usize,
1407 pub backlog: usize,
1408 pub todo: usize,
1409 pub in_progress: usize,
1410 pub blocked: usize,
1411 pub done: usize,
1412 pub cancelled: usize,
1413 pub overdue: usize,
1414 pub due_today: usize,
1415 pub projects: usize,
1416}
1417
1418#[cfg(test)]
1419mod tests {
1420 use super::*;
1421 use crate::memory::types::Recurrence;
1422 use tempfile::TempDir;
1423
1424 fn open_test_shared_db(path: &Path) -> Arc<DB> {
1425 let shared_path = path.join("shared");
1426 std::fs::create_dir_all(&shared_path).unwrap();
1427 let mut opts = Options::default();
1428 opts.create_if_missing(true);
1429 opts.create_missing_column_families(true);
1430 opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
1431 let cfs = vec![
1432 ColumnFamilyDescriptor::new("default", opts.clone()),
1433 ColumnFamilyDescriptor::new(CF_TODOS, opts.clone()),
1434 ColumnFamilyDescriptor::new(CF_PROJECTS, opts.clone()),
1435 ColumnFamilyDescriptor::new(CF_TODO_INDEX, opts.clone()),
1436 ];
1437 Arc::new(DB::open_cf_descriptors(&opts, &shared_path, cfs).unwrap())
1438 }
1439
1440 fn setup_store() -> (TodoStore, TempDir) {
1441 let temp_dir = TempDir::new().unwrap();
1442 let db = open_test_shared_db(temp_dir.path());
1443 let store = TodoStore::new(db, temp_dir.path()).unwrap();
1444 (store, temp_dir)
1445 }
1446
1447 #[test]
1448 fn test_create_and_get_todo() {
1449 let (store, _temp) = setup_store();
1450
1451 let todo = Todo::new("test_user".to_string(), "Test task".to_string());
1452 store.store_todo(&todo).unwrap();
1453
1454 let retrieved = store.get_todo("test_user", &todo.id).unwrap();
1455 assert!(retrieved.is_some());
1456 assert_eq!(retrieved.unwrap().content, "Test task");
1457 }
1458
1459 #[test]
1460 fn test_find_by_prefix() {
1461 let (store, _temp) = setup_store();
1462
1463 let todo = Todo::new("test_user".to_string(), "Test task".to_string());
1464 let stored = store.store_todo(&todo).unwrap();
1466
1467 let short_id = stored.short_id();
1469
1470 let found = store.find_todo_by_prefix("test_user", &short_id).unwrap();
1472 assert!(
1473 found.is_some(),
1474 "Should find by full short_id: {}",
1475 short_id
1476 );
1477
1478 let seq_str = stored.seq_num.to_string();
1480 let found2 = store.find_todo_by_prefix("test_user", &seq_str).unwrap();
1481 assert!(found2.is_some(), "Should find by seq_num: {}", seq_str);
1482
1483 let uuid_prefix = &stored.id.0.to_string()[..8];
1485 let found3 = store.find_todo_by_prefix("test_user", uuid_prefix).unwrap();
1486 assert!(
1487 found3.is_some(),
1488 "Should find by UUID prefix: {}",
1489 uuid_prefix
1490 );
1491 }
1492
1493 #[test]
1494 fn test_due_key_migration_and_ordering() {
1495 let temp_dir = TempDir::new().unwrap();
1496 let db = open_test_shared_db(temp_dir.path());
1497 let index_cf = db.cf_handle(CF_TODO_INDEX).unwrap();
1498
1499 let todo_id_a = Uuid::new_v4();
1500 let todo_id_b = Uuid::new_v4();
1501 db.put_cf(
1504 index_cf,
1505 format!("due:9:user1:{}", todo_id_a).as_bytes(),
1506 b"1",
1507 )
1508 .unwrap();
1509 db.put_cf(
1510 index_cf,
1511 format!("due:10:user1:{}", todo_id_b).as_bytes(),
1512 b"1",
1513 )
1514 .unwrap();
1515
1516 let migrated = migrate_due_key_padding(&db, index_cf).unwrap();
1518 assert_eq!(migrated, 2);
1519
1520 assert!(db
1522 .get_cf(index_cf, format!("due:9:user1:{}", todo_id_a).as_bytes())
1523 .unwrap()
1524 .is_none());
1525 assert!(db
1526 .get_cf(index_cf, format!("due:10:user1:{}", todo_id_b).as_bytes())
1527 .unwrap()
1528 .is_none());
1529
1530 let key_a = format!("due:{:020}:user1:{}", 9_i64, todo_id_a);
1532 let key_b = format!("due:{:020}:user1:{}", 10_i64, todo_id_b);
1533 assert!(db.get_cf(index_cf, key_a.as_bytes()).unwrap().is_some());
1534 assert!(db.get_cf(index_cf, key_b.as_bytes()).unwrap().is_some());
1535
1536 assert!(
1538 key_a < key_b,
1539 "Padded key for ts=9 should sort before ts=10"
1540 );
1541
1542 let migrated_again = migrate_due_key_padding(&db, index_cf).unwrap();
1544 assert_eq!(migrated_again, 0);
1545 }
1546
1547 #[test]
1548 fn test_complete_todo() {
1549 let (store, _temp) = setup_store();
1550
1551 let todo = Todo::new("test_user".to_string(), "Test task".to_string());
1552 store.store_todo(&todo).unwrap();
1553
1554 let result = store.complete_todo("test_user", &todo.id).unwrap();
1555 assert!(result.is_some());
1556
1557 let (completed, _next) = result.unwrap();
1558 assert_eq!(completed.status, TodoStatus::Done);
1559 assert!(completed.completed_at.is_some());
1560 }
1561
1562 #[test]
1563 fn test_recurring_todo() {
1564 let (store, _temp) = setup_store();
1565
1566 let mut todo = Todo::new("test_user".to_string(), "Daily task".to_string());
1567 todo.recurrence = Some(Recurrence::Daily);
1568 todo.due_date = Some(Utc::now());
1569 store.store_todo(&todo).unwrap();
1570
1571 let result = store.complete_todo("test_user", &todo.id).unwrap();
1572 assert!(result.is_some());
1573
1574 let (completed, next) = result.unwrap();
1575 assert_eq!(completed.status, TodoStatus::Done);
1576 assert!(next.is_some());
1577
1578 let next_todo = next.unwrap();
1579 assert_eq!(next_todo.status, TodoStatus::Todo);
1580 assert!(next_todo.due_date.unwrap() > completed.due_date.unwrap());
1581 }
1582
1583 #[test]
1584 fn test_project_crud() {
1585 let (store, _temp) = setup_store();
1586
1587 let project = Project::new("test_user".to_string(), "Test Project".to_string());
1588 store.store_project(&project).unwrap();
1589
1590 let found = store
1591 .find_project_by_name("test_user", "test project")
1592 .unwrap();
1593 assert!(found.is_some());
1594 assert_eq!(found.unwrap().name, "Test Project");
1595 }
1596
1597 #[test]
1598 fn test_list_by_status() {
1599 let (store, _temp) = setup_store();
1600
1601 let mut todo1 = Todo::new("test_user".to_string(), "Task 1".to_string());
1602 todo1.status = TodoStatus::InProgress;
1603
1604 let mut todo2 = Todo::new("test_user".to_string(), "Task 2".to_string());
1605 todo2.status = TodoStatus::Backlog;
1606
1607 store.store_todo(&todo1).unwrap();
1608 store.store_todo(&todo2).unwrap();
1609
1610 let in_progress = store
1611 .list_todos_for_user("test_user", Some(&[TodoStatus::InProgress]))
1612 .unwrap();
1613 assert_eq!(in_progress.len(), 1);
1614 assert_eq!(in_progress[0].content, "Task 1");
1615 }
1616}