1use 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#[derive(Debug, Error)]
24pub enum StorageError {
25 #[error("IO error: {0}")]
27 Io(#[from] std::io::Error),
28
29 #[error("Format error: {0}")]
31 Format(#[from] FormatError),
32
33 #[error("Session not found: {0}")]
35 SessionNotFound(String),
36
37 #[error("Project not found: {0}")]
39 ProjectNotFound(String),
40
41 #[error("Storage not initialized at {0}")]
44 NotInitialized(PathBuf),
45
46 #[error("Index error: {0}")]
49 Index(String),
50}
51
52pub type StorageResult<T> = Result<T, StorageError>;
54
55#[derive(Debug)]
64pub struct RyoStorage {
65 root: PathBuf,
67 sessions_dir: PathBuf,
69 projects_dir: PathBuf,
71 index_path: PathBuf,
73 project_index_path: PathBuf,
75 format: Format,
77 index: Option<SessionIndex>,
79 project_index: Option<ProjectIndex>,
81}
82
83impl RyoStorage {
84 pub const DIR_NAME: &'static str = ".ryo";
86 pub const SESSIONS_DIR: &'static str = "sessions";
88 pub const PROJECTS_DIR: &'static str = "projects";
90 pub const INDEX_FILE: &'static str = "index.json";
92 pub const PROJECT_INDEX_FILE: &'static str = "projects.json";
94
95 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 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 pub fn with_format(mut self, format: Format) -> Self {
127 self.format = format;
128 self
129 }
130
131 pub fn root(&self) -> &Path {
133 &self.root
134 }
135
136 pub fn is_initialized(&self) -> bool {
138 self.root.exists() && self.sessions_dir.exists()
139 }
140
141 pub fn init(&self) -> StorageResult<()> {
143 fs::create_dir_all(&self.sessions_dir)?;
144 fs::create_dir_all(&self.projects_dir)?;
145
146 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 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 pub fn ensure_init(&self) -> StorageResult<()> {
167 if !self.is_initialized() {
168 self.init()?;
169 }
170 Ok(())
171 }
172
173 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 let serializer = get_serializer(self.format);
189 let file = File::create(&path)?;
190 serializer.serialize_to_file(log, file)?;
191
192 self.add_to_index(log)?;
194
195 Ok(session_id)
196 }
197
198 pub fn load(&self, session_id: &str) -> StorageResult<TxLog> {
202 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 pub fn exists(&self, session_id: &str) -> bool {
223 self.find_session_path(session_id).is_some()
224 }
225
226 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 self.remove_from_index(session_id)?;
234
235 Ok(())
236 }
237
238 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 fn session_filename(&self, session_id: &str) -> String {
252 format!("{}.txlog.{}", session_id, self.format.extension())
253 }
254
255 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 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 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 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 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 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 pub fn list_sessions(&mut self) -> StorageResult<Vec<&SessionMeta>> {
325 Ok(self.index()?.list())
326 }
327
328 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 pub fn latest_session(&mut self) -> StorageResult<Option<&SessionMeta>> {
338 Ok(self.index()?.latest())
339 }
340
341 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 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 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 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 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 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 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 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 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 pub fn get_project(&mut self, project_id: &str) -> StorageResult<Option<&ProjectMeta>> {
457 Ok(self.project_index()?.get(project_id))
458 }
459
460 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 pub fn get_project_by_path_mut(
467 &mut self,
468 path: &Path,
469 ) -> StorageResult<Option<&mut ProjectMeta>> {
470 let project_id = self
472 .project_index()?
473 .get_by_path(path)
474 .map(|p| p.project_id.clone());
475
476 if let Some(id) = project_id {
478 Ok(self.project_index_mut()?.get_mut(&id))
479 } else {
480 Ok(None)
481 }
482 }
483
484 pub fn save_projects(&self) -> StorageResult<()> {
486 self.save_project_index()
487 }
488
489 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 pub fn is_project_registered(&mut self, path: &Path) -> StorageResult<bool> {
496 Ok(self.project_index()?.contains_path(path))
497 }
498
499 pub fn list_projects(&mut self) -> StorageResult<Vec<&ProjectMeta>> {
501 Ok(self.project_index()?.list())
502 }
503
504 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 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 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 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 pub const CACHE_DIR: &'static str = "cache";
550
551 pub fn cache_dir(&self) -> PathBuf {
553 self.root.join(Self::CACHE_DIR)
554 }
555
556 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 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 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 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 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 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 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 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 let all = storage.list_sessions().unwrap();
715 assert_eq!(all.len(), 3);
716
717 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}