Skip to main content

libgrite_core/store/
mod.rs

1use std::collections::HashSet;
2use std::fs::File;
3use std::path::Path;
4use std::time::{Duration, Instant};
5
6use fs2::FileExt;
7
8use crate::error::GriteError;
9use crate::types::event::{DependencyType, Event, EventKind};
10use crate::types::ids::{EventId, IssueId};
11use crate::types::issue::{IssueProjection, IssueSummary};
12use crate::types::event::IssueState;
13use crate::types::context::{FileContext, ProjectContextEntry};
14use crate::types::issue::Version;
15
16/// Default threshold for events since rebuild before recommending rebuild
17pub const DEFAULT_REBUILD_EVENTS_THRESHOLD: usize = 10000;
18
19/// Default threshold for days since rebuild before recommending rebuild
20pub const DEFAULT_REBUILD_DAYS_THRESHOLD: u32 = 7;
21
22/// Filter for listing issues
23#[derive(Debug, Default)]
24pub struct IssueFilter {
25    pub state: Option<IssueState>,
26    pub label: Option<String>,
27}
28
29/// Statistics about the database
30#[derive(Debug)]
31pub struct DbStats {
32    pub path: String,
33    pub size_bytes: u64,
34    pub event_count: usize,
35    pub issue_count: usize,
36    pub last_rebuild_ts: Option<u64>,
37    /// Events inserted since last rebuild
38    pub events_since_rebuild: usize,
39    /// Days since last rebuild
40    pub days_since_rebuild: Option<u32>,
41    /// Whether rebuild is recommended based on thresholds
42    pub rebuild_recommended: bool,
43}
44
45/// Statistics from a rebuild operation
46#[derive(Debug)]
47pub struct RebuildStats {
48    pub event_count: usize,
49    pub issue_count: usize,
50}
51
52/// A GritStore with filesystem-level exclusive lock.
53///
54/// The lock is held for the lifetime of this struct and automatically
55/// released when dropped. This prevents multiple processes from opening
56/// the same sled database concurrently.
57pub struct LockedStore {
58    /// Lock file handle - flock released on drop
59    _lock_file: File,
60    /// The underlying store
61    store: GritStore,
62}
63
64impl std::fmt::Debug for LockedStore {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.debug_struct("LockedStore")
67            .field("store", &"GritStore { ... }")
68            .finish()
69    }
70}
71
72impl LockedStore {
73    /// Get a reference to the inner GritStore
74    pub fn inner(&self) -> &GritStore {
75        &self.store
76    }
77}
78
79impl std::ops::Deref for LockedStore {
80    type Target = GritStore;
81
82    fn deref(&self) -> &Self::Target {
83        &self.store
84    }
85}
86
87impl std::ops::DerefMut for LockedStore {
88    fn deref_mut(&mut self) -> &mut Self::Target {
89        &mut self.store
90    }
91}
92
93/// Main storage interface backed by sled
94pub struct GritStore {
95    db: sled::Db,
96    events: sled::Tree,
97    issue_states: sled::Tree,
98    issue_events: sled::Tree,
99    label_index: sled::Tree,
100    metadata: sled::Tree,
101    dep_forward: sled::Tree,
102    dep_reverse: sled::Tree,
103    context_files: sled::Tree,
104    context_symbols: sled::Tree,
105    context_project: sled::Tree,
106}
107
108impl GritStore {
109    /// Open or create a store at the given path
110    pub fn open(path: &Path) -> Result<Self, GriteError> {
111        let db = sled::open(path)?;
112        let events = db.open_tree("events")?;
113        let issue_states = db.open_tree("issue_states")?;
114        let issue_events = db.open_tree("issue_events")?;
115        let label_index = db.open_tree("label_index")?;
116        let metadata = db.open_tree("metadata")?;
117        let dep_forward = db.open_tree("dep_forward")?;
118        let dep_reverse = db.open_tree("dep_reverse")?;
119        let context_files = db.open_tree("context_files")?;
120        let context_symbols = db.open_tree("context_symbols")?;
121        let context_project = db.open_tree("context_project")?;
122
123        Ok(Self {
124            db,
125            events,
126            issue_states,
127            issue_events,
128            label_index,
129            metadata,
130            dep_forward,
131            dep_reverse,
132            context_files,
133            context_symbols,
134            context_project,
135        })
136    }
137
138    /// Open store with exclusive filesystem lock (non-blocking).
139    ///
140    /// Lock file is created at `<path>.lock` (e.g., `.git/grite/actors/<id>/sled.lock`).
141    /// Returns `GriteError::DbBusy` if another process holds the lock.
142    pub fn open_locked(path: &Path) -> Result<LockedStore, GriteError> {
143        let lock_path = path.with_extension("lock");
144
145        // Create/open lock file
146        let lock_file = File::create(&lock_path)?;
147
148        // Try to acquire exclusive lock (non-blocking)
149        lock_file.try_lock_exclusive().map_err(|e| {
150            GriteError::DbBusy(format!("Database locked by another process: {}", e))
151        })?;
152
153        // Now safe to open sled
154        let store = Self::open(path)?;
155
156        Ok(LockedStore {
157            _lock_file: lock_file,
158            store,
159        })
160    }
161
162    /// Open store with exclusive filesystem lock (blocking with timeout).
163    ///
164    /// Retries with exponential backoff until the lock is acquired or timeout is reached.
165    /// Returns `GriteError::DbBusy` if timeout expires before acquiring the lock.
166    pub fn open_locked_blocking(path: &Path, timeout: Duration) -> Result<LockedStore, GriteError> {
167        let lock_path = path.with_extension("lock");
168        let lock_file = File::create(&lock_path)?;
169
170        let start = Instant::now();
171        let mut delay = Duration::from_millis(10);
172
173        loop {
174            match lock_file.try_lock_exclusive() {
175                Ok(()) => break,
176                Err(_) if start.elapsed() < timeout => {
177                    std::thread::sleep(delay);
178                    delay = (delay * 2).min(Duration::from_millis(200));
179                }
180                Err(e) => {
181                    return Err(GriteError::DbBusy(format!(
182                        "Timeout waiting for database lock: {}",
183                        e
184                    )))
185                }
186            }
187        }
188
189        let store = Self::open(path)?;
190        Ok(LockedStore {
191            _lock_file: lock_file,
192            store,
193        })
194    }
195
196    /// Insert an event and update projections
197    pub fn insert_event(&self, event: &Event) -> Result<(), GriteError> {
198        // Store the event
199        let event_key = event_key(&event.event_id);
200        let event_json = serde_json::to_vec(event)?;
201        self.events.insert(&event_key, event_json)?;
202
203        // Index by issue
204        let issue_events_key = issue_events_key(&event.issue_id, event.ts_unix_ms, &event.event_id);
205        self.issue_events.insert(&issue_events_key, &[])?;
206
207        // Update projection
208        self.update_projection(event)?;
209
210        // Increment events_since_rebuild counter
211        self.increment_events_since_rebuild()?;
212
213        Ok(())
214    }
215
216    /// Increment the events_since_rebuild counter
217    fn increment_events_since_rebuild(&self) -> Result<(), GriteError> {
218        let current = self.metadata.get("events_since_rebuild")?.map(|bytes| {
219            let arr: [u8; 8] = bytes.as_ref().try_into().unwrap_or([0; 8]);
220            u64::from_le_bytes(arr)
221        }).unwrap_or(0);
222
223        let new_count = current + 1;
224        self.metadata.insert("events_since_rebuild", &new_count.to_le_bytes())?;
225        Ok(())
226    }
227
228    /// Update the issue projection for an event
229    fn update_projection(&self, event: &Event) -> Result<(), GriteError> {
230        // Handle context events separately (they don't have issue projections)
231        match &event.kind {
232            EventKind::ContextUpdated { path, language, symbols, summary, content_hash } => {
233                return self.update_file_context(event, path, language, symbols, summary, content_hash);
234            }
235            EventKind::ProjectContextUpdated { key, value } => {
236                return self.update_project_context(event, key, value);
237            }
238            _ => {}
239        }
240
241        let issue_key = issue_state_key(&event.issue_id);
242
243        let mut projection = match self.issue_states.get(&issue_key)? {
244            Some(bytes) => serde_json::from_slice(&bytes)?,
245            None => {
246                // Must be IssueCreated
247                IssueProjection::from_event(event)?
248            }
249        };
250
251        // Apply event if not IssueCreated (which created the projection)
252        if self.issue_states.get(&issue_key)?.is_some() {
253            projection.apply(event)?;
254        }
255
256        // Update label index
257        for label in &projection.labels {
258            let label_key = label_index_key(label, &event.issue_id);
259            self.label_index.insert(&label_key, &[])?;
260        }
261
262        // Update dependency indexes
263        match &event.kind {
264            EventKind::DependencyAdded { target, dep_type } => {
265                let fwd = dep_forward_key(&event.issue_id, target, dep_type);
266                self.dep_forward.insert(&fwd, &[])?;
267                let rev = dep_reverse_key(target, &event.issue_id, dep_type);
268                self.dep_reverse.insert(&rev, &[])?;
269            }
270            EventKind::DependencyRemoved { target, dep_type } => {
271                let fwd = dep_forward_key(&event.issue_id, target, dep_type);
272                self.dep_forward.remove(&fwd)?;
273                let rev = dep_reverse_key(target, &event.issue_id, dep_type);
274                self.dep_reverse.remove(&rev)?;
275            }
276            _ => {}
277        }
278
279        // Store updated projection
280        let proj_json = serde_json::to_vec(&projection)?;
281        self.issue_states.insert(&issue_key, proj_json)?;
282
283        Ok(())
284    }
285
286    /// Update file context (LWW per path)
287    fn update_file_context(
288        &self,
289        event: &Event,
290        path: &str,
291        language: &str,
292        symbols: &[crate::types::event::SymbolInfo],
293        summary: &str,
294        content_hash: &[u8; 32],
295    ) -> Result<(), GriteError> {
296        let file_key = context_file_key(path);
297        let new_version = Version::new(event.ts_unix_ms, event.actor, event.event_id);
298
299        let should_update = match self.context_files.get(&file_key)? {
300            Some(existing_bytes) => {
301                let existing: FileContext = serde_json::from_slice(&existing_bytes)?;
302                new_version.is_newer_than(&existing.version)
303            }
304            None => true,
305        };
306
307        if should_update {
308            // Remove old symbol index entries for this path
309            let sym_path_suffix = format!("/{}", path);
310            for result in self.context_symbols.iter() {
311                let (key, _) = result?;
312                if let Ok(key_str) = std::str::from_utf8(&key) {
313                    if key_str.ends_with(&sym_path_suffix) {
314                        self.context_symbols.remove(&key)?;
315                    }
316                }
317            }
318
319            let ctx = FileContext {
320                path: path.to_string(),
321                language: language.to_string(),
322                symbols: symbols.to_vec(),
323                summary: summary.to_string(),
324                content_hash: *content_hash,
325                version: new_version,
326            };
327
328            // Insert file context
329            self.context_files.insert(&file_key, serde_json::to_vec(&ctx)?)?;
330
331            // Insert symbol index entries
332            for sym in symbols {
333                let sym_key = context_symbol_key(&sym.name, path);
334                self.context_symbols.insert(&sym_key, &[])?;
335            }
336        }
337
338        Ok(())
339    }
340
341    /// Update project context (LWW per key)
342    fn update_project_context(
343        &self,
344        event: &Event,
345        key: &str,
346        value: &str,
347    ) -> Result<(), GriteError> {
348        let proj_key = context_project_key(key);
349        let new_version = Version::new(event.ts_unix_ms, event.actor, event.event_id);
350
351        let should_update = match self.context_project.get(&proj_key)? {
352            Some(existing_bytes) => {
353                let existing: ProjectContextEntry = serde_json::from_slice(&existing_bytes)?;
354                new_version.is_newer_than(&existing.version)
355            }
356            None => true,
357        };
358
359        if should_update {
360            let entry = ProjectContextEntry {
361                value: value.to_string(),
362                version: new_version,
363            };
364            self.context_project.insert(&proj_key, serde_json::to_vec(&entry)?)?;
365        }
366
367        Ok(())
368    }
369
370    /// Get an event by ID
371    pub fn get_event(&self, event_id: &EventId) -> Result<Option<Event>, GriteError> {
372        let key = event_key(event_id);
373        match self.events.get(&key)? {
374            Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
375            None => Ok(None),
376        }
377    }
378
379    /// Get an issue projection by ID
380    pub fn get_issue(&self, issue_id: &IssueId) -> Result<Option<IssueProjection>, GriteError> {
381        let key = issue_state_key(issue_id);
382        match self.issue_states.get(&key)? {
383            Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
384            None => Ok(None),
385        }
386    }
387
388    /// List issues with optional filtering
389    pub fn list_issues(&self, filter: &IssueFilter) -> Result<Vec<IssueSummary>, GriteError> {
390        let mut summaries = Vec::new();
391
392        for result in self.issue_states.iter() {
393            let (_, value) = result?;
394            let proj: IssueProjection = serde_json::from_slice(&value)?;
395
396            // Apply filters
397            if let Some(state) = filter.state {
398                if proj.state != state {
399                    continue;
400                }
401            }
402            if let Some(ref label) = filter.label {
403                if !proj.labels.contains(label) {
404                    continue;
405                }
406            }
407
408            summaries.push(IssueSummary::from(&proj));
409        }
410
411        // Sort by issue_id (lexicographic)
412        summaries.sort_by(|a, b| a.issue_id.cmp(&b.issue_id));
413
414        Ok(summaries)
415    }
416
417    /// Get all events for an issue, sorted by (ts, actor, event_id)
418    pub fn get_issue_events(&self, issue_id: &IssueId) -> Result<Vec<Event>, GriteError> {
419        let prefix = issue_events_prefix(issue_id);
420        let mut events = Vec::new();
421
422        for result in self.issue_events.scan_prefix(&prefix) {
423            let (key, _) = result?;
424            // Extract event_id from key
425            let event_id = extract_event_id_from_issue_events_key(&key)?;
426            if let Some(event) = self.get_event(&event_id)? {
427                events.push(event);
428            }
429        }
430
431        // Sort by (ts, actor, event_id)
432        events.sort_by(|a, b| {
433            (a.ts_unix_ms, &a.actor, &a.event_id).cmp(&(b.ts_unix_ms, &b.actor, &b.event_id))
434        });
435
436        Ok(events)
437    }
438
439    /// Get all events in the store
440    pub fn get_all_events(&self) -> Result<Vec<Event>, GriteError> {
441        let mut events = Vec::new();
442        for result in self.events.iter() {
443            let (_, value) = result?;
444            let event: Event = serde_json::from_slice(&value)?;
445            events.push(event);
446        }
447        // Sort by (issue_id, ts, actor, event_id)
448        events.sort_by(|a, b| {
449            (&a.issue_id, a.ts_unix_ms, &a.actor, &a.event_id)
450                .cmp(&(&b.issue_id, b.ts_unix_ms, &b.actor, &b.event_id))
451        });
452        Ok(events)
453    }
454
455    /// Rebuild all projections from events
456    pub fn rebuild(&self) -> Result<RebuildStats, GriteError> {
457        // Clear existing projections and indexes
458        self.issue_states.clear()?;
459        self.label_index.clear()?;
460        self.dep_forward.clear()?;
461        self.dep_reverse.clear()?;
462        self.context_files.clear()?;
463        self.context_symbols.clear()?;
464        self.context_project.clear()?;
465
466        // Collect all events
467        let mut events = self.get_all_events()?;
468
469        // Sort events by (issue_id, ts, actor, event_id) for deterministic ordering
470        events.sort_by(|a, b| {
471            (&a.issue_id, a.ts_unix_ms, &a.actor, &a.event_id)
472                .cmp(&(&b.issue_id, b.ts_unix_ms, &b.actor, &b.event_id))
473        });
474
475        // Rebuild projections
476        for event in &events {
477            self.update_projection(event)?;
478        }
479
480        let issue_count = self.issue_states.len();
481
482        // Update rebuild timestamp and reset counter
483        let now = std::time::SystemTime::now()
484            .duration_since(std::time::UNIX_EPOCH)
485            .unwrap()
486            .as_millis() as u64;
487        self.metadata.insert("last_rebuild_ts", &now.to_le_bytes())?;
488        self.metadata.insert("events_since_rebuild", &0u64.to_le_bytes())?;
489
490        Ok(RebuildStats {
491            event_count: events.len(),
492            issue_count,
493        })
494    }
495
496    /// Rebuild all projections from provided events (for snapshot-based rebuild)
497    ///
498    /// This is useful when rebuilding from a snapshot + WAL combination,
499    /// where events come from external sources rather than the local store.
500    pub fn rebuild_from_events(&self, events: &[Event]) -> Result<RebuildStats, GriteError> {
501        // Clear existing projections, indexes, and events
502        self.issue_states.clear()?;
503        self.label_index.clear()?;
504        self.dep_forward.clear()?;
505        self.dep_reverse.clear()?;
506        self.context_files.clear()?;
507        self.context_symbols.clear()?;
508        self.context_project.clear()?;
509        self.events.clear()?;
510
511        // Sort events by (issue_id, ts, actor, event_id) for deterministic ordering
512        let mut sorted_events: Vec<_> = events.to_vec();
513        sorted_events.sort_by(|a, b| {
514            (&a.issue_id, a.ts_unix_ms, &a.actor, &a.event_id)
515                .cmp(&(&b.issue_id, b.ts_unix_ms, &b.actor, &b.event_id))
516        });
517
518        // Insert events and rebuild projections
519        for event in &sorted_events {
520            // Insert event into store
521            let ev_key = event_key(&event.event_id);
522            let event_json = serde_json::to_vec(event)?;
523            self.events.insert(&ev_key, event_json)?;
524
525            // Index by issue
526            let ie_key = issue_events_key(&event.issue_id, event.ts_unix_ms, &event.event_id);
527            self.issue_events.insert(&ie_key, &[])?;
528
529            // Rebuild projection (handles deps, context, labels)
530            self.update_projection(event)?;
531        }
532
533        let issue_count = self.issue_states.len();
534
535        // Update rebuild timestamp and reset counter
536        let now = std::time::SystemTime::now()
537            .duration_since(std::time::UNIX_EPOCH)
538            .unwrap()
539            .as_millis() as u64;
540        self.metadata.insert("last_rebuild_ts", &now.to_le_bytes())?;
541        self.metadata.insert("events_since_rebuild", &0u64.to_le_bytes())?;
542
543        Ok(RebuildStats {
544            event_count: sorted_events.len(),
545            issue_count,
546        })
547    }
548
549    /// Get database statistics
550    pub fn stats(&self, path: &Path) -> Result<DbStats, GriteError> {
551        let event_count = self.events.len();
552        let issue_count = self.issue_states.len();
553
554        // Calculate size by walking the directory
555        let size_bytes = dir_size(path).unwrap_or(0);
556
557        let last_rebuild_ts = self.metadata.get("last_rebuild_ts")?.map(|bytes| {
558            let arr: [u8; 8] = bytes.as_ref().try_into().unwrap_or([0; 8]);
559            u64::from_le_bytes(arr)
560        });
561
562        let events_since_rebuild = self.metadata.get("events_since_rebuild")?.map(|bytes| {
563            let arr: [u8; 8] = bytes.as_ref().try_into().unwrap_or([0; 8]);
564            u64::from_le_bytes(arr) as usize
565        }).unwrap_or(event_count); // If never rebuilt, assume all events are since rebuild
566
567        // Calculate days since last rebuild
568        let now_ms = std::time::SystemTime::now()
569            .duration_since(std::time::UNIX_EPOCH)
570            .unwrap()
571            .as_millis() as u64;
572
573        let days_since_rebuild = last_rebuild_ts.map(|ts| {
574            let ms_diff = now_ms.saturating_sub(ts);
575            (ms_diff / (24 * 60 * 60 * 1000)) as u32
576        });
577
578        // Recommend rebuild if events > 10000 or days > 7
579        let rebuild_recommended = events_since_rebuild > DEFAULT_REBUILD_EVENTS_THRESHOLD
580            || days_since_rebuild.map(|d| d > DEFAULT_REBUILD_DAYS_THRESHOLD).unwrap_or(false);
581
582        Ok(DbStats {
583            path: path.to_string_lossy().to_string(),
584            size_bytes,
585            event_count,
586            issue_count,
587            last_rebuild_ts,
588            events_since_rebuild,
589            days_since_rebuild,
590            rebuild_recommended,
591        })
592    }
593
594    // --- Dependency Query Methods ---
595
596    /// Get all outgoing dependencies for an issue
597    pub fn get_dependencies(&self, issue_id: &IssueId) -> Result<Vec<(IssueId, DependencyType)>, GriteError> {
598        let prefix = dep_forward_prefix(issue_id);
599        let mut deps = Vec::new();
600
601        for result in self.dep_forward.scan_prefix(&prefix) {
602            let (key, _) = result?;
603            if let Some((target, dep_type)) = parse_dep_key_suffix(&key, prefix.len()) {
604                deps.push((target, dep_type));
605            }
606        }
607
608        Ok(deps)
609    }
610
611    /// Get all incoming dependencies (what depends on this issue)
612    pub fn get_dependents(&self, issue_id: &IssueId) -> Result<Vec<(IssueId, DependencyType)>, GriteError> {
613        let prefix = dep_reverse_prefix(issue_id);
614        let mut deps = Vec::new();
615
616        for result in self.dep_reverse.scan_prefix(&prefix) {
617            let (key, _) = result?;
618            if let Some((source, dep_type)) = parse_dep_key_suffix(&key, prefix.len()) {
619                deps.push((source, dep_type));
620            }
621        }
622
623        Ok(deps)
624    }
625
626    /// Check if adding a dependency would create a cycle.
627    /// Only checks for Blocks/DependsOn (acyclic types).
628    pub fn would_create_cycle(
629        &self,
630        source: &IssueId,
631        target: &IssueId,
632        dep_type: &DependencyType,
633    ) -> Result<bool, GriteError> {
634        if !dep_type.is_acyclic() {
635            return Ok(false);
636        }
637
638        // DFS from target: can we reach source via forward deps?
639        let mut visited = HashSet::new();
640        let mut stack = vec![*target];
641
642        while let Some(current) = stack.pop() {
643            if current == *source {
644                return Ok(true);
645            }
646            if !visited.insert(current) {
647                continue;
648            }
649            for (dep_target, dt) in self.get_dependencies(&current)? {
650                if dt == *dep_type {
651                    stack.push(dep_target);
652                }
653            }
654        }
655
656        Ok(false)
657    }
658
659    /// Get issues in topological order based on dependency relationships.
660    /// Issues with no dependencies come first.
661    pub fn topological_order(&self, filter: &IssueFilter) -> Result<Vec<IssueSummary>, GriteError> {
662        let issues = self.list_issues(filter)?;
663        let issue_ids: HashSet<IssueId> = issues.iter().map(|i| i.issue_id).collect();
664
665        // Build in-degree map (only count edges within the filtered set)
666        let mut in_degree: std::collections::HashMap<IssueId, usize> = std::collections::HashMap::new();
667        let mut adj: std::collections::HashMap<IssueId, Vec<IssueId>> = std::collections::HashMap::new();
668
669        for issue in &issues {
670            in_degree.entry(issue.issue_id).or_insert(0);
671            adj.entry(issue.issue_id).or_default();
672
673            for (target, dep_type) in self.get_dependencies(&issue.issue_id)? {
674                if dep_type.is_acyclic() && issue_ids.contains(&target) {
675                    // issue depends on target, so target must come first
676                    adj.entry(target).or_default().push(issue.issue_id);
677                    *in_degree.entry(issue.issue_id).or_insert(0) += 1;
678                }
679            }
680        }
681
682        // Kahn's algorithm
683        let mut queue: std::collections::VecDeque<IssueId> = in_degree.iter()
684            .filter(|(_, &deg)| deg == 0)
685            .map(|(&id, _)| id)
686            .collect();
687
688        let mut sorted_ids = Vec::new();
689        while let Some(id) = queue.pop_front() {
690            sorted_ids.push(id);
691            if let Some(neighbors) = adj.get(&id) {
692                for &neighbor in neighbors {
693                    if let Some(deg) = in_degree.get_mut(&neighbor) {
694                        *deg -= 1;
695                        if *deg == 0 {
696                            queue.push_back(neighbor);
697                        }
698                    }
699                }
700            }
701        }
702
703        // Any remaining issues (cycles) go at the end
704        for issue in &issues {
705            if !sorted_ids.contains(&issue.issue_id) {
706                sorted_ids.push(issue.issue_id);
707            }
708        }
709
710        // Map back to summaries in sorted order
711        let issue_map: std::collections::HashMap<IssueId, &IssueSummary> =
712            issues.iter().map(|i| (i.issue_id, i)).collect();
713        let result = sorted_ids.iter()
714            .filter_map(|id| issue_map.get(id).map(|s| (*s).clone()))
715            .collect();
716
717        Ok(result)
718    }
719
720    // --- Context Query Methods ---
721
722    /// Get file context for a specific path
723    pub fn get_file_context(&self, path: &str) -> Result<Option<FileContext>, GriteError> {
724        let key = context_file_key(path);
725        match self.context_files.get(&key)? {
726            Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
727            None => Ok(None),
728        }
729    }
730
731    /// Query symbols by name prefix
732    pub fn query_symbols(&self, query: &str) -> Result<Vec<(String, String)>, GriteError> {
733        let prefix = context_symbol_prefix(query);
734        let mut results = Vec::new();
735
736        for result in self.context_symbols.scan_prefix(&prefix) {
737            let (key, _) = result?;
738            if let Ok(key_str) = std::str::from_utf8(&key) {
739                // Key format: "ctx/sym/<name>/<path>"
740                if let Some(rest) = key_str.strip_prefix("ctx/sym/") {
741                    if let Some(slash_pos) = rest.find('/') {
742                        let name = rest[..slash_pos].to_string();
743                        let path = rest[slash_pos + 1..].to_string();
744                        results.push((name, path));
745                    }
746                }
747            }
748        }
749
750        Ok(results)
751    }
752
753    /// List all indexed file paths
754    pub fn list_context_files(&self) -> Result<Vec<String>, GriteError> {
755        let mut paths = Vec::new();
756        for result in self.context_files.iter() {
757            let (key, _) = result?;
758            if let Ok(key_str) = std::str::from_utf8(&key) {
759                if let Some(path) = key_str.strip_prefix("ctx/file/") {
760                    paths.push(path.to_string());
761                }
762            }
763        }
764        Ok(paths)
765    }
766
767    /// Get a project context entry by key
768    pub fn get_project_context(&self, key: &str) -> Result<Option<ProjectContextEntry>, GriteError> {
769        let k = context_project_key(key);
770        match self.context_project.get(&k)? {
771            Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
772            None => Ok(None),
773        }
774    }
775
776    /// List all project context entries
777    pub fn list_project_context(&self) -> Result<Vec<(String, ProjectContextEntry)>, GriteError> {
778        let mut entries = Vec::new();
779        for result in self.context_project.iter() {
780            let (key, value) = result?;
781            if let Ok(key_str) = std::str::from_utf8(&key) {
782                if let Some(k) = key_str.strip_prefix("ctx/proj/") {
783                    let entry: ProjectContextEntry = serde_json::from_slice(&value)?;
784                    entries.push((k.to_string(), entry));
785                }
786            }
787        }
788        Ok(entries)
789    }
790
791    /// Flush pending writes to disk
792    pub fn flush(&self) -> Result<(), GriteError> {
793        self.db.flush()?;
794        Ok(())
795    }
796}
797
798// Key construction helpers
799
800fn event_key(event_id: &EventId) -> Vec<u8> {
801    let mut key = Vec::with_capacity(6 + 32);
802    key.extend_from_slice(b"event/");
803    key.extend_from_slice(event_id);
804    key
805}
806
807fn issue_state_key(issue_id: &IssueId) -> Vec<u8> {
808    let mut key = Vec::with_capacity(12 + 16);
809    key.extend_from_slice(b"issue_state/");
810    key.extend_from_slice(issue_id);
811    key
812}
813
814fn issue_events_prefix(issue_id: &IssueId) -> Vec<u8> {
815    let mut key = Vec::with_capacity(13 + 16);
816    key.extend_from_slice(b"issue_events/");
817    key.extend_from_slice(issue_id);
818    key.push(b'/');
819    key
820}
821
822fn issue_events_key(issue_id: &IssueId, ts: u64, event_id: &EventId) -> Vec<u8> {
823    let mut key = issue_events_prefix(issue_id);
824    key.extend_from_slice(&ts.to_be_bytes());
825    key.push(b'/');
826    key.extend_from_slice(event_id);
827    key
828}
829
830fn label_index_key(label: &str, issue_id: &IssueId) -> Vec<u8> {
831    let mut key = Vec::with_capacity(12 + label.len() + 1 + 16);
832    key.extend_from_slice(b"label_index/");
833    key.extend_from_slice(label.as_bytes());
834    key.push(b'/');
835    key.extend_from_slice(issue_id);
836    key
837}
838
839// Dependency key helpers
840
841fn dep_type_to_byte(dep_type: &DependencyType) -> u8 {
842    match dep_type {
843        DependencyType::Blocks => b'B',
844        DependencyType::DependsOn => b'D',
845        DependencyType::RelatedTo => b'R',
846    }
847}
848
849fn byte_to_dep_type(b: u8) -> Option<DependencyType> {
850    match b {
851        b'B' => Some(DependencyType::Blocks),
852        b'D' => Some(DependencyType::DependsOn),
853        b'R' => Some(DependencyType::RelatedTo),
854        _ => None,
855    }
856}
857
858fn dep_forward_prefix(source: &IssueId) -> Vec<u8> {
859    let mut key = Vec::with_capacity(8 + 16 + 1);
860    key.extend_from_slice(b"dep_fwd/");
861    key.extend_from_slice(source);
862    key.push(b'/');
863    key
864}
865
866fn dep_forward_key(source: &IssueId, target: &IssueId, dep_type: &DependencyType) -> Vec<u8> {
867    let mut key = dep_forward_prefix(source);
868    key.extend_from_slice(target);
869    key.push(b'/');
870    key.push(dep_type_to_byte(dep_type));
871    key
872}
873
874fn dep_reverse_prefix(target: &IssueId) -> Vec<u8> {
875    let mut key = Vec::with_capacity(8 + 16 + 1);
876    key.extend_from_slice(b"dep_rev/");
877    key.extend_from_slice(target);
878    key.push(b'/');
879    key
880}
881
882fn dep_reverse_key(target: &IssueId, source: &IssueId, dep_type: &DependencyType) -> Vec<u8> {
883    let mut key = dep_reverse_prefix(target);
884    key.extend_from_slice(source);
885    key.push(b'/');
886    key.push(dep_type_to_byte(dep_type));
887    key
888}
889
890/// Parse the suffix of a dep key (after the prefix) to extract target/source and dep_type
891fn parse_dep_key_suffix(key: &[u8], prefix_len: usize) -> Option<(IssueId, DependencyType)> {
892    // Suffix format: <issue_id 16 bytes> / <dep_type 1 byte>
893    let suffix = &key[prefix_len..];
894    if suffix.len() != 16 + 1 + 1 {
895        return None;
896    }
897    let mut issue_id = [0u8; 16];
898    issue_id.copy_from_slice(&suffix[..16]);
899    // suffix[16] is '/'
900    let dep_type = byte_to_dep_type(suffix[17])?;
901    Some((issue_id, dep_type))
902}
903
904// Context key helpers
905
906fn context_file_key(path: &str) -> Vec<u8> {
907    let mut key = Vec::new();
908    key.extend_from_slice(b"ctx/file/");
909    key.extend_from_slice(path.as_bytes());
910    key
911}
912
913fn context_symbol_prefix(name: &str) -> Vec<u8> {
914    let mut key = Vec::new();
915    key.extend_from_slice(b"ctx/sym/");
916    key.extend_from_slice(name.as_bytes());
917    key
918}
919
920fn context_symbol_key(name: &str, path: &str) -> Vec<u8> {
921    let mut key = context_symbol_prefix(name);
922    key.push(b'/');
923    key.extend_from_slice(path.as_bytes());
924    key
925}
926
927fn context_project_key(key_name: &str) -> Vec<u8> {
928    let mut key = Vec::new();
929    key.extend_from_slice(b"ctx/proj/");
930    key.extend_from_slice(key_name.as_bytes());
931    key
932}
933
934fn extract_event_id_from_issue_events_key(key: &[u8]) -> Result<EventId, GriteError> {
935    // Key format: "issue_events/" + issue_id (16) + "/" + ts (8) + "/" + event_id (32)
936    // Total: 13 + 16 + 1 + 8 + 1 + 32 = 71
937    if key.len() < 71 {
938        return Err(GriteError::Internal("Invalid issue_events key".to_string()));
939    }
940    let event_id_start = key.len() - 32;
941    let mut event_id = [0u8; 32];
942    event_id.copy_from_slice(&key[event_id_start..]);
943    Ok(event_id)
944}
945
946fn dir_size(path: &Path) -> std::io::Result<u64> {
947    let mut size = 0;
948    if path.is_dir() {
949        for entry in std::fs::read_dir(path)? {
950            let entry = entry?;
951            let meta = entry.metadata()?;
952            if meta.is_dir() {
953                size += dir_size(&entry.path())?;
954            } else {
955                size += meta.len();
956            }
957        }
958    }
959    Ok(size)
960}
961
962#[cfg(test)]
963mod tests {
964    use super::*;
965    use crate::hash::compute_event_id;
966    use crate::types::event::EventKind;
967    use crate::types::ids::generate_issue_id;
968    use tempfile::tempdir;
969
970    fn make_event(issue_id: IssueId, actor: [u8; 16], ts: u64, kind: EventKind) -> Event {
971        let event_id = compute_event_id(&issue_id, &actor, ts, None, &kind);
972        Event::new(event_id, issue_id, actor, ts, None, kind)
973    }
974
975    #[test]
976    fn test_store_basic_operations() {
977        let dir = tempdir().unwrap();
978        let store = GritStore::open(dir.path()).unwrap();
979
980        let issue_id = generate_issue_id();
981        let actor = [1u8; 16];
982
983        // Create an issue
984        let create_event = make_event(
985            issue_id,
986            actor,
987            1000,
988            EventKind::IssueCreated {
989                title: "Test Issue".to_string(),
990                body: "Test body".to_string(),
991                labels: vec!["bug".to_string()],
992            },
993        );
994
995        store.insert_event(&create_event).unwrap();
996
997        // Verify event was stored
998        let retrieved = store.get_event(&create_event.event_id).unwrap().unwrap();
999        assert_eq!(retrieved.event_id, create_event.event_id);
1000
1001        // Verify projection was created
1002        let proj = store.get_issue(&issue_id).unwrap().unwrap();
1003        assert_eq!(proj.title, "Test Issue");
1004        assert!(proj.labels.contains("bug"));
1005    }
1006
1007    #[test]
1008    fn test_store_list_issues() {
1009        let dir = tempdir().unwrap();
1010        let store = GritStore::open(dir.path()).unwrap();
1011
1012        let actor = [1u8; 16];
1013
1014        // Create two issues
1015        for i in 0..2 {
1016            let issue_id = generate_issue_id();
1017            let event = make_event(
1018                issue_id,
1019                actor,
1020                1000 + i,
1021                EventKind::IssueCreated {
1022                    title: format!("Issue {}", i),
1023                    body: "Body".to_string(),
1024                    labels: vec![],
1025                },
1026            );
1027            store.insert_event(&event).unwrap();
1028        }
1029
1030        let issues = store.list_issues(&IssueFilter::default()).unwrap();
1031        assert_eq!(issues.len(), 2);
1032    }
1033
1034    #[test]
1035    fn test_store_rebuild() {
1036        let dir = tempdir().unwrap();
1037        let store = GritStore::open(dir.path()).unwrap();
1038
1039        let issue_id = generate_issue_id();
1040        let actor = [1u8; 16];
1041
1042        // Create and modify an issue
1043        let events = vec![
1044            make_event(
1045                issue_id,
1046                actor,
1047                1000,
1048                EventKind::IssueCreated {
1049                    title: "Test".to_string(),
1050                    body: "Body".to_string(),
1051                    labels: vec![],
1052                },
1053            ),
1054            make_event(
1055                issue_id,
1056                actor,
1057                2000,
1058                EventKind::IssueUpdated {
1059                    title: Some("Updated".to_string()),
1060                    body: None,
1061                },
1062            ),
1063        ];
1064
1065        for event in &events {
1066            store.insert_event(event).unwrap();
1067        }
1068
1069        // Get projection before rebuild
1070        let proj_before = store.get_issue(&issue_id).unwrap().unwrap();
1071        assert_eq!(proj_before.title, "Updated");
1072
1073        // Rebuild
1074        let stats = store.rebuild().unwrap();
1075        assert_eq!(stats.event_count, 2);
1076        assert_eq!(stats.issue_count, 1);
1077
1078        // Verify projection is the same after rebuild
1079        let proj_after = store.get_issue(&issue_id).unwrap().unwrap();
1080        assert_eq!(proj_after.title, "Updated");
1081    }
1082
1083    #[test]
1084    fn test_locked_store_creates_lock_file() {
1085        let dir = tempdir().unwrap();
1086        let store_path = dir.path().join("sled");
1087        let lock_path = dir.path().join("sled.lock");
1088
1089        // Lock file shouldn't exist yet
1090        assert!(!lock_path.exists());
1091
1092        // Open locked store
1093        let _store = GritStore::open_locked(&store_path).unwrap();
1094
1095        // Lock file should now exist
1096        assert!(lock_path.exists());
1097    }
1098
1099    #[test]
1100    fn test_locked_store_second_open_fails() {
1101        let dir = tempdir().unwrap();
1102        let store_path = dir.path().join("sled");
1103
1104        // First open succeeds
1105        let _store1 = GritStore::open_locked(&store_path).unwrap();
1106
1107        // Second open should fail with DbBusy
1108        let result = GritStore::open_locked(&store_path);
1109        assert!(result.is_err());
1110        match result.unwrap_err() {
1111            GriteError::DbBusy(msg) => {
1112                assert!(msg.contains("locked"));
1113            }
1114            other => panic!("Expected DbBusy error, got {:?}", other),
1115        }
1116    }
1117
1118    #[test]
1119    fn test_locked_store_released_on_drop() {
1120        let dir = tempdir().unwrap();
1121        let store_path = dir.path().join("sled");
1122
1123        // First open
1124        {
1125            let _store = GritStore::open_locked(&store_path).unwrap();
1126            // Store is dropped here
1127        }
1128
1129        // Second open should succeed after drop
1130        let _store2 = GritStore::open_locked(&store_path).unwrap();
1131    }
1132
1133    #[test]
1134    fn test_locked_store_blocking_timeout() {
1135        let dir = tempdir().unwrap();
1136        let store_path = dir.path().join("sled");
1137
1138        // First open succeeds
1139        let _store1 = GritStore::open_locked(&store_path).unwrap();
1140
1141        // Blocking open with very short timeout should fail
1142        let result = GritStore::open_locked_blocking(&store_path, Duration::from_millis(50));
1143        assert!(result.is_err());
1144        match result.unwrap_err() {
1145            GriteError::DbBusy(msg) => {
1146                assert!(msg.contains("Timeout"));
1147            }
1148            other => panic!("Expected DbBusy timeout error, got {:?}", other),
1149        }
1150    }
1151
1152    #[test]
1153    fn test_locked_store_deref_access() {
1154        let dir = tempdir().unwrap();
1155        let store_path = dir.path().join("sled");
1156
1157        let store = GritStore::open_locked(&store_path).unwrap();
1158
1159        // Verify we can access GritStore methods through Deref
1160        let issue_id = generate_issue_id();
1161        let actor = [1u8; 16];
1162        let event = make_event(
1163            issue_id,
1164            actor,
1165            1000,
1166            EventKind::IssueCreated {
1167                title: "Test".to_string(),
1168                body: "Body".to_string(),
1169                labels: vec![],
1170            },
1171        );
1172
1173        // These calls go through Deref to GritStore
1174        store.insert_event(&event).unwrap();
1175        let retrieved = store.get_event(&event.event_id).unwrap();
1176        assert!(retrieved.is_some());
1177    }
1178}