Skip to main content

ryo_storage/storage/
store.rs

1//! Global RYO storage management.
2//!
3//! Manages the `~/.ryo/` directory structure:
4//! ```text
5//! ~/.ryo/
6//! ├── sessions/           # Session transaction logs
7//! │   ├── {id}.txlog.json
8//! │   └── ...
9//! ├── index.json          # Session metadata index
10//! └── config.toml         # (future) Global configuration
11//! ```
12
13use super::format::{get_serializer, Format, FormatError};
14use super::index::{SessionIndex, SessionMeta};
15use super::project::{ProjectIndex, ProjectMeta};
16use crate::txlog::TxLog;
17use std::fs::{self, File};
18use std::io::BufReader;
19use std::path::{Path, PathBuf};
20use thiserror::Error;
21
22/// Errors from storage operations.
23#[derive(Debug, Error)]
24pub enum StorageError {
25    /// Underlying filesystem I/O failure.
26    #[error("IO error: {0}")]
27    Io(#[from] std::io::Error),
28
29    /// Session payload (de)serialization failure; wraps [`FormatError`].
30    #[error("Format error: {0}")]
31    Format(#[from] FormatError),
32
33    /// The requested session id does not exist in the store.
34    #[error("Session not found: {0}")]
35    SessionNotFound(String),
36
37    /// The requested project id does not exist in the store.
38    #[error("Project not found: {0}")]
39    ProjectNotFound(String),
40
41    /// The storage directory has not been initialized yet (no metadata
42    /// file). Carries the directory that was inspected.
43    #[error("Storage not initialized at {0}")]
44    NotInitialized(PathBuf),
45
46    /// Session/project index lookup or update failure. Carries a rendered
47    /// message for diagnostics.
48    #[error("Index error: {0}")]
49    Index(String),
50}
51
52/// Result type for storage operations.
53pub type StorageResult<T> = Result<T, StorageError>;
54
55/// Global RYO storage manager.
56///
57/// Provides high-level API for:
58/// - Dumping session logs
59/// - Loading past sessions
60/// - Querying session history
61/// - Managing imported projects
62/// - Managing storage lifecycle
63#[derive(Debug)]
64pub struct RyoStorage {
65    /// Root directory (~/.ryo/)
66    root: PathBuf,
67    /// Sessions directory (~/.ryo/sessions/)
68    sessions_dir: PathBuf,
69    /// Projects directory (~/.ryo/projects/)
70    projects_dir: PathBuf,
71    /// Session index file path (~/.ryo/index.json)
72    index_path: PathBuf,
73    /// Project index file path (~/.ryo/projects.json)
74    project_index_path: PathBuf,
75    /// Serialization format
76    format: Format,
77    /// Cached session index (lazy-loaded)
78    index: Option<SessionIndex>,
79    /// Cached project index (lazy-loaded)
80    project_index: Option<ProjectIndex>,
81}
82
83impl RyoStorage {
84    /// Default RYO directory name.
85    pub const DIR_NAME: &'static str = ".ryo";
86    /// Sessions subdirectory name.
87    pub const SESSIONS_DIR: &'static str = "sessions";
88    /// Projects subdirectory name.
89    pub const PROJECTS_DIR: &'static str = "projects";
90    /// Session index file name.
91    pub const INDEX_FILE: &'static str = "index.json";
92    /// Project index file name.
93    pub const PROJECT_INDEX_FILE: &'static str = "projects.json";
94
95    /// Create a new storage manager at the default location (~/.ryo/).
96    pub fn global() -> StorageResult<Self> {
97        let home = dirs::home_dir().ok_or_else(|| {
98            StorageError::Io(std::io::Error::new(
99                std::io::ErrorKind::NotFound,
100                "Could not find home directory",
101            ))
102        })?;
103        Self::new(home.join(Self::DIR_NAME))
104    }
105
106    /// Create a new storage manager at a custom location.
107    pub fn new(root: PathBuf) -> StorageResult<Self> {
108        let sessions_dir = root.join(Self::SESSIONS_DIR);
109        let projects_dir = root.join(Self::PROJECTS_DIR);
110        let index_path = root.join(Self::INDEX_FILE);
111        let project_index_path = root.join(Self::PROJECT_INDEX_FILE);
112
113        Ok(Self {
114            root,
115            sessions_dir,
116            projects_dir,
117            index_path,
118            project_index_path,
119            format: Format::default(),
120            index: None,
121            project_index: None,
122        })
123    }
124
125    /// Set the serialization format.
126    pub fn with_format(mut self, format: Format) -> Self {
127        self.format = format;
128        self
129    }
130
131    /// Get the root directory path.
132    pub fn root(&self) -> &Path {
133        &self.root
134    }
135
136    /// Check if storage is initialized (directories exist).
137    pub fn is_initialized(&self) -> bool {
138        self.root.exists() && self.sessions_dir.exists()
139    }
140
141    /// Initialize storage directories.
142    pub fn init(&self) -> StorageResult<()> {
143        fs::create_dir_all(&self.sessions_dir)?;
144        fs::create_dir_all(&self.projects_dir)?;
145
146        // Create empty session index if it doesn't exist
147        if !self.index_path.exists() {
148            let index = SessionIndex::new();
149            let json = serde_json::to_string_pretty(&index)
150                .map_err(|e| StorageError::Format(FormatError::Json(e)))?;
151            fs::write(&self.index_path, json)?;
152        }
153
154        // Create empty project index if it doesn't exist
155        if !self.project_index_path.exists() {
156            let index = ProjectIndex::new();
157            let json = serde_json::to_string_pretty(&index)
158                .map_err(|e| StorageError::Format(FormatError::Json(e)))?;
159            fs::write(&self.project_index_path, json)?;
160        }
161
162        Ok(())
163    }
164
165    /// Ensure storage is initialized, creating directories if needed.
166    pub fn ensure_init(&self) -> StorageResult<()> {
167        if !self.is_initialized() {
168            self.init()?;
169        }
170        Ok(())
171    }
172
173    // ========================================================================
174    // Session Dump/Load
175    // ========================================================================
176
177    /// Dump a session log to storage.
178    ///
179    /// Returns the session ID.
180    pub fn dump(&mut self, log: &TxLog) -> StorageResult<String> {
181        self.ensure_init()?;
182
183        let session_id = log.session_id.clone();
184        let filename = self.session_filename(&session_id);
185        let path = self.sessions_dir.join(&filename);
186
187        // Serialize and write
188        let serializer = get_serializer(self.format);
189        let file = File::create(&path)?;
190        serializer.serialize_to_file(log, file)?;
191
192        // Update index
193        self.add_to_index(log)?;
194
195        Ok(session_id)
196    }
197
198    /// Load a session log by ID.
199    ///
200    /// This method automatically detects the file format by trying all known formats.
201    pub fn load(&self, session_id: &str) -> StorageResult<TxLog> {
202        // Try the configured format first, then fall back to other formats
203        let formats_to_try = [self.format, Format::Json, Format::JsonCompact];
204
205        for format in formats_to_try {
206            let filename = format!("{}.txlog.{}", session_id, format.extension());
207            let path = self.sessions_dir.join(&filename);
208
209            if path.exists() {
210                let file = File::open(&path)?;
211                let reader = BufReader::new(file);
212                let serializer = get_serializer(format);
213                let log = serializer.deserialize_from_reader(reader)?;
214                return Ok(log);
215            }
216        }
217
218        Err(StorageError::SessionNotFound(session_id.to_string()))
219    }
220
221    /// Check if a session exists (in any format).
222    pub fn exists(&self, session_id: &str) -> bool {
223        self.find_session_path(session_id).is_some()
224    }
225
226    /// Delete a session (in any format).
227    pub fn delete(&mut self, session_id: &str) -> StorageResult<()> {
228        if let Some(path) = self.find_session_path(session_id) {
229            fs::remove_file(&path)?;
230        }
231
232        // Update index
233        self.remove_from_index(session_id)?;
234
235        Ok(())
236    }
237
238    /// Find the path to a session file, trying all known formats.
239    fn find_session_path(&self, session_id: &str) -> Option<PathBuf> {
240        for format in [Format::Json, Format::JsonCompact] {
241            let filename = format!("{}.txlog.{}", session_id, format.extension());
242            let path = self.sessions_dir.join(&filename);
243            if path.exists() {
244                return Some(path);
245            }
246        }
247        None
248    }
249
250    /// Generate filename for a session.
251    fn session_filename(&self, session_id: &str) -> String {
252        format!("{}.txlog.{}", session_id, self.format.extension())
253    }
254
255    // ========================================================================
256    // Index Management
257    // ========================================================================
258
259    /// Get the session index.
260    pub fn index(&mut self) -> StorageResult<&SessionIndex> {
261        if self.index.is_none() {
262            self.load_index()?;
263        }
264        Ok(self
265            .index
266            .as_ref()
267            .expect("load_index() above sets self.index to Some"))
268    }
269
270    /// Get mutable access to the session index.
271    fn index_mut(&mut self) -> StorageResult<&mut SessionIndex> {
272        if self.index.is_none() {
273            self.load_index()?;
274        }
275        Ok(self
276            .index
277            .as_mut()
278            .expect("load_index() above sets self.index to Some"))
279    }
280
281    /// Load the index from disk.
282    fn load_index(&mut self) -> StorageResult<()> {
283        if self.index_path.exists() {
284            let content = fs::read_to_string(&self.index_path)?;
285            let index: SessionIndex = serde_json::from_str(&content)
286                .map_err(|e| StorageError::Format(FormatError::Json(e)))?;
287            self.index = Some(index);
288        } else {
289            self.index = Some(SessionIndex::new());
290        }
291        Ok(())
292    }
293
294    /// Save the index to disk.
295    fn save_index(&self) -> StorageResult<()> {
296        if let Some(ref index) = self.index {
297            let json = serde_json::to_string_pretty(index)
298                .map_err(|e| StorageError::Format(FormatError::Json(e)))?;
299            fs::write(&self.index_path, json)?;
300        }
301        Ok(())
302    }
303
304    /// Add a session to the index.
305    fn add_to_index(&mut self, log: &TxLog) -> StorageResult<()> {
306        let meta = SessionMeta::from_log(log);
307        self.index_mut()?.add(meta);
308        self.save_index()?;
309        Ok(())
310    }
311
312    /// Remove a session from the index.
313    fn remove_from_index(&mut self, session_id: &str) -> StorageResult<()> {
314        self.index_mut()?.remove(session_id);
315        self.save_index()?;
316        Ok(())
317    }
318
319    // ========================================================================
320    // Query API
321    // ========================================================================
322
323    /// List all sessions.
324    pub fn list_sessions(&mut self) -> StorageResult<Vec<&SessionMeta>> {
325        Ok(self.index()?.list())
326    }
327
328    /// Find sessions by project path.
329    pub fn sessions_for_project(
330        &mut self,
331        project_path: &Path,
332    ) -> StorageResult<Vec<&SessionMeta>> {
333        Ok(self.index()?.by_project(project_path))
334    }
335
336    /// Get the most recent session.
337    pub fn latest_session(&mut self) -> StorageResult<Option<&SessionMeta>> {
338        Ok(self.index()?.latest())
339    }
340
341    /// Get the most recent session for a project.
342    pub fn latest_for_project(
343        &mut self,
344        project_path: &Path,
345    ) -> StorageResult<Option<&SessionMeta>> {
346        Ok(self.index()?.latest_for_project(project_path))
347    }
348
349    // ========================================================================
350    // Maintenance
351    // ========================================================================
352
353    /// Clean up old sessions (keep last N per project).
354    pub fn cleanup(&mut self, keep_per_project: usize) -> StorageResult<usize> {
355        let to_delete = self.index_mut()?.cleanup(keep_per_project);
356        let count = to_delete.len();
357
358        for session_id in to_delete {
359            let filename = self.session_filename(&session_id);
360            let path = self.sessions_dir.join(&filename);
361            if path.exists() {
362                fs::remove_file(&path)?;
363            }
364        }
365
366        self.save_index()?;
367        Ok(count)
368    }
369
370    /// Get total storage size in bytes.
371    pub fn storage_size(&self) -> StorageResult<u64> {
372        let mut total = 0u64;
373
374        if self.sessions_dir.exists() {
375            for entry in fs::read_dir(&self.sessions_dir)? {
376                let entry = entry?;
377                if entry.file_type()?.is_file() {
378                    total += entry.metadata()?.len();
379                }
380            }
381        }
382
383        if self.index_path.exists() {
384            total += fs::metadata(&self.index_path)?.len();
385        }
386
387        Ok(total)
388    }
389
390    // ========================================================================
391    // Project Management
392    // ========================================================================
393
394    /// Get the project index.
395    pub fn project_index(&mut self) -> StorageResult<&ProjectIndex> {
396        if self.project_index.is_none() {
397            self.load_project_index()?;
398        }
399        Ok(self
400            .project_index
401            .as_ref()
402            .expect("load_project_index() above sets self.project_index to Some"))
403    }
404
405    /// Get mutable access to the project index.
406    fn project_index_mut(&mut self) -> StorageResult<&mut ProjectIndex> {
407        if self.project_index.is_none() {
408            self.load_project_index()?;
409        }
410        Ok(self
411            .project_index
412            .as_mut()
413            .expect("load_project_index() above sets self.project_index to Some"))
414    }
415
416    /// Load the project index from disk.
417    fn load_project_index(&mut self) -> StorageResult<()> {
418        if self.project_index_path.exists() {
419            let content = fs::read_to_string(&self.project_index_path)?;
420            let index: ProjectIndex = serde_json::from_str(&content)
421                .map_err(|e| StorageError::Format(FormatError::Json(e)))?;
422            self.project_index = Some(index);
423        } else {
424            self.project_index = Some(ProjectIndex::new());
425        }
426        Ok(())
427    }
428
429    /// Save the project index to disk.
430    fn save_project_index(&self) -> StorageResult<()> {
431        if let Some(ref index) = self.project_index {
432            let json = serde_json::to_string_pretty(index)
433                .map_err(|e| StorageError::Format(FormatError::Json(e)))?;
434            fs::write(&self.project_index_path, json)?;
435        }
436        Ok(())
437    }
438
439    /// Register a project in the index.
440    pub fn register_project(&mut self, meta: ProjectMeta) -> StorageResult<String> {
441        self.ensure_init()?;
442        let project_id = meta.project_id.clone();
443        self.project_index_mut()?.add(meta);
444        self.save_project_index()?;
445        Ok(project_id)
446    }
447
448    /// Unregister a project from the index.
449    pub fn unregister_project(&mut self, project_id: &str) -> StorageResult<Option<ProjectMeta>> {
450        let meta = self.project_index_mut()?.remove(project_id);
451        self.save_project_index()?;
452        Ok(meta)
453    }
454
455    /// Get a project by ID.
456    pub fn get_project(&mut self, project_id: &str) -> StorageResult<Option<&ProjectMeta>> {
457        Ok(self.project_index()?.get(project_id))
458    }
459
460    /// Get a project by path.
461    pub fn get_project_by_path(&mut self, path: &Path) -> StorageResult<Option<&ProjectMeta>> {
462        Ok(self.project_index()?.get_by_path(path))
463    }
464
465    /// Get a mutable project by path.
466    pub fn get_project_by_path_mut(
467        &mut self,
468        path: &Path,
469    ) -> StorageResult<Option<&mut ProjectMeta>> {
470        // First find the project_id
471        let project_id = self
472            .project_index()?
473            .get_by_path(path)
474            .map(|p| p.project_id.clone());
475
476        // Then get mutable reference
477        if let Some(id) = project_id {
478            Ok(self.project_index_mut()?.get_mut(&id))
479        } else {
480            Ok(None)
481        }
482    }
483
484    /// Save the project index (public for external updates).
485    pub fn save_projects(&self) -> StorageResult<()> {
486        self.save_project_index()
487    }
488
489    /// Search projects by name pattern.
490    pub fn search_projects_by_name(&mut self, pattern: &str) -> StorageResult<Vec<&ProjectMeta>> {
491        Ok(self.project_index()?.search_by_name(pattern))
492    }
493
494    /// Check if a project path is already registered.
495    pub fn is_project_registered(&mut self, path: &Path) -> StorageResult<bool> {
496        Ok(self.project_index()?.contains_path(path))
497    }
498
499    /// List all registered projects.
500    pub fn list_projects(&mut self) -> StorageResult<Vec<&ProjectMeta>> {
501        Ok(self.project_index()?.list())
502    }
503
504    /// Touch a project (update last accessed time).
505    pub fn touch_project(&mut self, project_id: &str) -> StorageResult<()> {
506        if let Some(meta) = self.project_index_mut()?.get_mut(project_id) {
507            meta.touch();
508            self.save_project_index()?;
509        }
510        Ok(())
511    }
512
513    /// Get project statistics.
514    pub fn project_stats(&mut self) -> StorageResult<(usize, usize, usize)> {
515        let index = self.project_index()?;
516        Ok((index.count(), index.total_files(), index.total_lines()))
517    }
518
519    /// Cleanup dead server PIDs from all projects.
520    ///
521    /// Checks each project's server_pid and clears it if the process is no longer running.
522    /// Returns the number of projects that were cleaned up.
523    pub fn cleanup_dead_servers(&mut self) -> StorageResult<usize> {
524        let cleaned = self.project_index_mut()?.cleanup_dead_servers();
525        if cleaned > 0 {
526            self.save_project_index()?;
527        }
528        Ok(cleaned)
529    }
530
531    /// List all registered projects, with optional dead server cleanup.
532    ///
533    /// If `cleanup` is true, dead server PIDs are automatically cleared before listing.
534    pub fn list_projects_with_cleanup(
535        &mut self,
536        cleanup: bool,
537    ) -> StorageResult<Vec<&ProjectMeta>> {
538        if cleanup {
539            self.cleanup_dead_servers()?;
540        }
541        Ok(self.project_index()?.list())
542    }
543
544    // ========================================================================
545    // CodeGraph Cache
546    // ========================================================================
547
548    /// Cache subdirectory name.
549    pub const CACHE_DIR: &'static str = "cache";
550
551    /// Get the cache directory path.
552    pub fn cache_dir(&self) -> PathBuf {
553        self.root.join(Self::CACHE_DIR)
554    }
555
556    /// Ensure cache directory exists.
557    fn ensure_cache_dir(&self) -> StorageResult<()> {
558        let dir = self.cache_dir();
559        if !dir.exists() {
560            fs::create_dir_all(&dir)?;
561        }
562        Ok(())
563    }
564
565    /// Save a CodeGraph cache.
566    ///
567    /// Returns the cache file path.
568    pub fn save_graph_cache(&self, project_hash: &str, data: &[u8]) -> StorageResult<PathBuf> {
569        self.ensure_cache_dir()?;
570        let path = self.cache_dir().join(format!("{}.graph.bin", project_hash));
571        fs::write(&path, data)?;
572        Ok(path)
573    }
574
575    /// Load a CodeGraph cache.
576    ///
577    /// Returns None if cache doesn't exist.
578    pub fn load_graph_cache(&self, project_hash: &str) -> StorageResult<Option<Vec<u8>>> {
579        let path = self.cache_dir().join(format!("{}.graph.bin", project_hash));
580        if !path.exists() {
581            return Ok(None);
582        }
583        let data = fs::read(&path)?;
584        Ok(Some(data))
585    }
586
587    /// Delete a CodeGraph cache.
588    pub fn delete_graph_cache(&self, project_hash: &str) -> StorageResult<()> {
589        let path = self.cache_dir().join(format!("{}.graph.bin", project_hash));
590        if path.exists() {
591            fs::remove_file(&path)?;
592        }
593        Ok(())
594    }
595
596    /// List all cached project hashes.
597    pub fn list_graph_caches(&self) -> StorageResult<Vec<String>> {
598        let dir = self.cache_dir();
599        if !dir.exists() {
600            return Ok(Vec::new());
601        }
602
603        let mut hashes = Vec::new();
604        for entry in fs::read_dir(&dir)? {
605            let entry = entry?;
606            let path = entry.path();
607            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
608                if name.ends_with(".graph.bin") {
609                    let hash = name.trim_end_matches(".graph.bin").to_string();
610                    hashes.push(hash);
611                }
612            }
613        }
614        Ok(hashes)
615    }
616
617    /// Get cache storage size in bytes.
618    pub fn cache_size(&self) -> StorageResult<u64> {
619        let dir = self.cache_dir();
620        if !dir.exists() {
621            return Ok(0);
622        }
623
624        let mut size = 0u64;
625        for entry in fs::read_dir(&dir)? {
626            let entry = entry?;
627            if let Ok(meta) = entry.metadata() {
628                size += meta.len();
629            }
630        }
631        Ok(size)
632    }
633
634    /// Clear all graph caches.
635    pub fn clear_graph_caches(&self) -> StorageResult<usize> {
636        let dir = self.cache_dir();
637        if !dir.exists() {
638            return Ok(0);
639        }
640
641        let mut count = 0;
642        for entry in fs::read_dir(&dir)? {
643            let entry = entry?;
644            let path = entry.path();
645            if path.extension().map(|e| e == "bin").unwrap_or(false) {
646                fs::remove_file(&path)?;
647                count += 1;
648            }
649        }
650        Ok(count)
651    }
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657    use crate::txlog::TxAction;
658    use tempfile::TempDir;
659
660    fn create_test_log(project: &str) -> TxLog {
661        let mut log = TxLog::with_project(project);
662        log.log(TxAction::GoalSet {
663            query: "test".to_string(),
664            intent_type: "test".to_string(),
665            confidence: 0.9,
666        });
667        log
668    }
669
670    #[test]
671    fn test_init_and_dump() {
672        let temp = TempDir::new().unwrap();
673        let mut storage = RyoStorage::new(temp.path().join(".ryo")).unwrap();
674
675        storage.init().unwrap();
676        assert!(storage.is_initialized());
677
678        let log = create_test_log("/test/project");
679        let session_id = storage.dump(&log).unwrap();
680
681        assert!(storage.exists(&session_id));
682    }
683
684    #[test]
685    fn test_load_session() {
686        let temp = TempDir::new().unwrap();
687        let mut storage = RyoStorage::new(temp.path().join(".ryo")).unwrap();
688        storage.init().unwrap();
689
690        let log = create_test_log("/test/project");
691        let session_id = storage.dump(&log).unwrap();
692
693        let loaded = storage.load(&session_id).unwrap();
694        assert_eq!(loaded.session_id, log.session_id);
695        assert_eq!(loaded.entries().len(), log.entries().len());
696    }
697
698    #[test]
699    fn test_session_index() {
700        let temp = TempDir::new().unwrap();
701        let mut storage = RyoStorage::new(temp.path().join(".ryo")).unwrap();
702        storage.init().unwrap();
703
704        // Dump multiple sessions
705        let log1 = create_test_log("/project/a");
706        let log2 = create_test_log("/project/b");
707        let log3 = create_test_log("/project/a");
708
709        storage.dump(&log1).unwrap();
710        storage.dump(&log2).unwrap();
711        storage.dump(&log3).unwrap();
712
713        // List all
714        let all = storage.list_sessions().unwrap();
715        assert_eq!(all.len(), 3);
716
717        // Filter by project
718        let proj_a = storage
719            .sessions_for_project(Path::new("/project/a"))
720            .unwrap();
721        assert_eq!(proj_a.len(), 2);
722    }
723
724    #[test]
725    fn test_delete_session() {
726        let temp = TempDir::new().unwrap();
727        let mut storage = RyoStorage::new(temp.path().join(".ryo")).unwrap();
728        storage.init().unwrap();
729
730        let log = create_test_log("/test/project");
731        let session_id = storage.dump(&log).unwrap();
732
733        assert!(storage.exists(&session_id));
734        storage.delete(&session_id).unwrap();
735        assert!(!storage.exists(&session_id));
736    }
737}