Skip to main content

shodh_memory/memory/
todos.rs

1//! GTD-style Todo Management (Linear-inspired)
2//!
3//! Features:
4//! - CRUD operations for todos and projects
5//! - Status-based workflow (Backlog -> Todo -> InProgress -> Done)
6//! - Priority levels (Urgent, High, Medium, Low)
7//! - GTD contexts (@computer, @phone, @errands, etc.)
8//! - Project grouping
9//! - Recurring tasks with automatic next instance creation
10//! - Due date tracking with overdue detection
11//! - Vector embeddings for semantic search (MiniLM-L6-v2)
12//! - Vamana HNSW index for fast similarity search
13
14use 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
29/// Embedding dimension (MiniLM-L6-v2)
30const EMBEDDING_DIM: usize = 384;
31
32const CF_TODOS: &str = "todos";
33const CF_PROJECTS: &str = "projects";
34const CF_TODO_INDEX: &str = "todo_index";
35
36/// Migrate unpadded `due:{ts}:{uid}:{id}` keys to zero-padded `due:{:020}:{uid}:{id}` format.
37///
38/// Prior versions wrote bare timestamps (e.g. `due:1739404800:user:uuid`), which break
39/// lexicographic ordering (`"9" > "10"`). Zero-padding to 20 digits ensures
40/// lex order = chronological order, enabling ordered range scans.
41fn 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        // Key format: due:{timestamp}:{user_id}:{todo_id}
50        let parts: Vec<&str> = key_str.splitn(4, ':').collect();
51        if parts.len() != 4 {
52            continue;
53        }
54
55        // Already padded — nothing to do
56        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
77/// Storage and query engine for todos and projects
78pub struct TodoStore {
79    /// Shared RocksDB instance with column families for todos, projects, and indices
80    db: Arc<DB>,
81    /// Vector index for semantic search (per-user indices)
82    vector_indices: RwLock<HashMap<String, VamanaIndex>>,
83    /// Storage path for persisting vector indices
84    storage_path: std::path::PathBuf,
85    /// Mutex for atomic sequence number allocation (prevents TOCTOU race)
86    seq_mutex: parking_lot::Mutex<()>,
87}
88
89impl TodoStore {
90    /// Column family descriptors required by the TodoStore.
91    /// The caller must include these (plus `"default"`) when opening the shared DB.
92    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    /// Create a new todo store backed by the given shared DB
118    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        // Migrate from old separate-DB layout if needed
123        Self::migrate_from_separate_dbs(&todos_path, &db)?;
124
125        // Migrate any unpadded due keys from prior versions
126        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    /// Migrate data from the old separate-DB layout (items/, projects/, index/ sub-dirs)
142    /// into the unified column-family DB. After migration, old dirs are renamed to
143    /// `{name}.pre_cf_migration` so the migration is idempotent.
144    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    /// Get or create a Vamana vector index for a user
196    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    /// Add or update a todo in the vector index
213    /// Returns the vector ID assigned to this todo
214    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            // Add vector and get assigned ID
225            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    /// Search for similar todos by embedding
232    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            // Find todos by vector_id (stored in todo_index CF)
243            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    /// Get a todo by its vector index ID (stored in todo_index CF)
256    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    /// Store the mapping from vector_id to todo_id (and reverse)
268    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        // Forward: vector_id → todo_id (for search result resolution)
278        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        // Reverse: todo_id → vector_id (for cleanup on delete)
286        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    /// Save vector indices to disk
294    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    /// Load vector indices from disk
305    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                // Create a new index and load from disk
319                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    // =========================================================================
334    // SEQUENCE NUMBER MANAGEMENT
335    // =========================================================================
336
337    /// Get the next sequence number for a project (or user if no project) and increment the counter
338    /// Key format: "seq:{user_id}:{project_id}" or "seq:{user_id}:_standalone_" for todos without project
339    fn next_seq_num(&self, user_id: &str, project_id: Option<&ProjectId>) -> Result<u32> {
340        // Hold mutex to prevent TOCTOU race on concurrent seq_num allocation
341        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    /// Assign a sequence number and project prefix to a todo if it doesn't have one
364    pub fn assign_seq_num(&self, todo: &mut Todo) -> Result<()> {
365        if todo.seq_num == 0 {
366            // Set project prefix if todo has a project
367            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    // =========================================================================
379    // TODO CRUD OPERATIONS
380    // =========================================================================
381
382    /// Store a new todo (assigns seq_num and project_prefix if needed, returns stored todo)
383    pub fn store_todo(&self, todo: &Todo) -> Result<Todo> {
384        // If seq_num is 0, assign one (for new todos)
385        let mut todo_to_store = todo.clone();
386        if todo_to_store.seq_num == 0 {
387            // Set project prefix if todo has a project
388            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    /// Update todo indices
421    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        // Index by user (for listing)
427        let user_key = format!("user:{}:{}", todo.user_id, id_str);
428        batch.put_cf(index_cf, user_key.as_bytes(), b"1");
429
430        // Index by status
431        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        // Index by priority
435        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        // Index by project
444        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        // Index by due date (zero-padded for correct lexicographic ordering)
450        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        // Index by context
456        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        // Index by parent (for subtasks)
462        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    /// Remove todo indices and clean up vector embeddings
475    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        // Clean up vector index mapping: look up vector_id from reverse mapping
515        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                // Mark deleted in Vamana index
522                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                // Remove forward mapping
528                let fwd_key = format!("vector_id:{}:{}", todo.user_id, vector_id);
529                batch.delete_cf(index_cf, fwd_key.as_bytes());
530            }
531            // Remove reverse mapping
532            batch.delete_cf(index_cf, rev_key.as_bytes());
533        }
534
535        self.db.write(batch)?;
536        Ok(())
537    }
538
539    /// Get a todo by ID
540    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    /// Find todo by short ID prefix (e.g., "BOLT-1", "MEM-2", "SHO-3", or just "1")
555    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        // Parse prefix in format "PREFIX-NUMBER" or just "NUMBER"
559        let prefix_upper = prefix.trim().to_uppercase();
560
561        // Try to extract project prefix and sequence number
562        if let Some((project_prefix, seq_str)) = prefix_upper.rsplit_once('-') {
563            // Format: "BOLT-1", "MEM-2", "SHO-3"
564            if let Ok(seq_num) = seq_str.parse::<u32>() {
565                // Find todo matching both project prefix and seq_num
566                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        // Try parsing as just a number (e.g., "1", "2")
579        if let Ok(seq_num) = prefix_upper.parse::<u32>() {
580            // Exact match on sequential number (any project)
581            if let Some(todo) = todos.iter().find(|t| t.seq_num == seq_num) {
582                return Ok(Some(todo.clone()));
583            }
584        }
585
586        // Fall back to UUID prefix matching (for legacy todos)
587        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    /// Find todo by external ID (e.g., "todoist:123", "linear:SHO-39")
614    /// Used for two-way sync with external todo/task management systems
615    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    /// Update a todo
623    pub fn update_todo(&self, todo: &Todo) -> Result<()> {
624        // Get old todo to remove old indices
625        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    /// Delete a todo
633    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            // Cascade delete subtasks to prevent orphans
638            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    /// Complete a todo (marks as Done, handles recurrence)
664    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            // Remove old indices
671            self.remove_todo_indices(&todo)?;
672
673            // Mark as complete
674            todo.complete();
675
676            // Store updated todo
677            let stored_todo = self.store_todo(&todo)?;
678
679            // Create next recurrence if applicable
680            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    // =========================================================================
693    // TODO COMMENTS
694    // =========================================================================
695
696    /// Add a comment to a todo
697    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    /// Add a system activity entry to a todo
727    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    /// Update a comment on a todo
738    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    /// Delete a comment from a todo
761    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    /// Get all comments for a todo
782    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    /// Reorder a todo within its status group
791    /// direction: "up" moves earlier in list (lower sort_order), "down" moves later
792    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        // Get all todos with the same status
804        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        // Sort by sort_order to get current ordering
810        same_status_todos.sort_by_key(|t| t.sort_order);
811
812        // Find current position
813        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)); // Already at top
822                }
823                pos - 1
824            }
825            "down" => {
826                if pos >= same_status_todos.len() - 1 {
827                    return Ok(Some(todo)); // Already at bottom
828                }
829                pos + 1
830            }
831            _ => return Ok(Some(todo)), // Invalid direction
832        };
833
834        // Swap sort_order values with adjacent todo
835        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        // Update both todos
841        current.updated_at = Utc::now();
842        adjacent.updated_at = Utc::now();
843
844        self.update_todo(&current)?;
845        self.update_todo(&adjacent)?;
846
847        Ok(Some(current))
848    }
849
850    // =========================================================================
851    // TODO QUERIES
852    // =========================================================================
853
854    /// List todos for a user with optional status filter
855    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            // Extract todo_id from key "user:{user_id}:{todo_id}"
876            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                    // Apply status filter
881                    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        // Sort by: sort_order (manual), then priority, then due date
893        todos.sort_by(|a, b| {
894            // First by sort_order (lower = higher in list)
895            let order_cmp = a.sort_order.cmp(&b.sort_order);
896            if order_cmp != std::cmp::Ordering::Equal {
897                return order_cmp;
898            }
899            // Then by priority
900            let priority_cmp = a.priority.value().cmp(&b.priority.value());
901            if priority_cmp != std::cmp::Ordering::Equal {
902                return priority_cmp;
903            }
904            // Finally by due date
905            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    /// List todos by project
917    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    /// List todos by context (e.g., @computer)
949    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    /// List due/overdue todos
978    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    /// List subtasks of a parent todo
1009    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    // =========================================================================
1039    // PROJECT CRUD OPERATIONS
1040    // =========================================================================
1041
1042    /// Store a project
1043    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        // Index by user
1050        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")?; // 'p' for project
1053
1054        // Index by name (for lookup) - store as string for easy parsing
1055        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        // Index by parent (for sub-projects)
1067        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    /// Get a project by ID
1082    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    /// Find project by name
1096    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    /// Find or create project by name
1109    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    /// List projects for a user
1120    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        // Sort by name
1140        projects.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
1141
1142        Ok(projects)
1143    }
1144
1145    /// List sub-projects of a parent project
1146    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            // Extract project ID from key
1163            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        // Sort by name
1174        subprojects.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
1175
1176        Ok(subprojects)
1177    }
1178
1179    /// Get project with todo counts
1180    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    /// Update a project's properties
1200    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                    // Set completed_at when archiving or completing
1240                    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                // Update name index if name changed
1257                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    /// Delete a project (and optionally its todos)
1274    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            // Delete todos if requested
1282            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            // Delete sub-projects recursively
1290            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            // Delete project
1296            let key = format!("{}:{}", user_id, project_id.0);
1297            self.db.delete_cf(self.projects_cf(), key.as_bytes())?;
1298
1299            // Delete indices
1300            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            // Delete parent index (if this was a sub-project)
1309            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    /// Purge a user's vector index from memory (used during GDPR user deletion)
1325    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    // =========================================================================
1331    // STATS
1332    // =========================================================================
1333
1334    /// Flush all RocksDB column families and save vector indices to disk (critical for graceful shutdown)
1335    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        // Save vector indices
1349        self.save_vector_indices()
1350            .map_err(|e| anyhow::anyhow!("Failed to save todo vector indices: {e}"))?;
1351
1352        Ok(())
1353    }
1354
1355    /// Get reference to the shared RocksDB database for backup
1356    pub fn databases(&self) -> Vec<(&str, &Arc<DB>)> {
1357        vec![("todos_shared", &self.db)]
1358    }
1359
1360    /// Get overall todo stats for a user
1361    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/// Stats for a single project
1392#[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/// Overall todo stats for a user
1404#[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        // store_todo assigns seq_num and returns the updated todo
1465        let stored = store.store_todo(&todo).unwrap();
1466
1467        // Use short_id() which returns "SHO-1" format (sequence-based)
1468        let short_id = stored.short_id();
1469
1470        // Find by full SHO-N format
1471        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        // Find by just the sequence number
1479        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        // Also test UUID prefix fallback for legacy compatibility
1484        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        // ts_a = 9 (1 digit), ts_b = 10 (2 digits)
1502        // Without padding: "due:9:..." > "due:10:..." lexicographically (wrong)
1503        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        // Run migration
1517        let migrated = migrate_due_key_padding(&db, index_cf).unwrap();
1518        assert_eq!(migrated, 2);
1519
1520        // Verify old keys are gone
1521        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        // Verify new padded keys exist
1531        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        // Verify lexicographic order is now correct: 9 < 10
1537        assert!(
1538            key_a < key_b,
1539            "Padded key for ts=9 should sort before ts=10"
1540        );
1541
1542        // Re-running migration should be a no-op
1543        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}