Skip to main content

agentic_reality_mcp/session/
mod.rs

1//! Session management for the MCP server.
2//!
3//! Manages the reality engine lifecycle, persistence via .areal files,
4//! and workspace identity derivation.
5
6use std::path::{Path, PathBuf};
7
8use agentic_reality::engine::RealityEngine;
9use agentic_reality::format::{ArealReader, ArealWriter};
10
11/// Manages the reality engine session.
12pub struct SessionManager {
13    /// The reality engine instance.
14    pub engine: RealityEngine,
15    /// Path to the .areal data file (if persistence is enabled).
16    pub data_path: Option<String>,
17    /// Workspace identifier derived from the canonical data path.
18    pub workspace_id: Option<String>,
19    /// Whether autosave is enabled.
20    pub autosave: bool,
21}
22
23impl SessionManager {
24    /// Create a new session with no persistence.
25    pub fn new() -> Self {
26        Self {
27            engine: RealityEngine::new(),
28            data_path: None,
29            workspace_id: None,
30            autosave: false,
31        }
32    }
33
34    /// Create a new session with a data path for persistence.
35    pub fn with_path(path: String) -> Self {
36        let workspace_id = derive_workspace_id(&path);
37        Self {
38            engine: RealityEngine::new(),
39            data_path: Some(path),
40            workspace_id,
41            autosave: false,
42        }
43    }
44
45    /// Enable or disable autosave.
46    pub fn set_autosave(&mut self, enabled: bool) {
47        self.autosave = enabled;
48    }
49
50    /// Whether the engine has unsaved changes.
51    pub fn is_dirty(&self) -> bool {
52        self.engine.is_dirty()
53    }
54
55    /// Save the session to disk if a data path is configured.
56    ///
57    /// Returns `Ok(true)` if data was written, `Ok(false)` if no path is set.
58    pub fn save(&mut self) -> Result<bool, SessionError> {
59        let path_str = match &self.data_path {
60            Some(p) => p.clone(),
61            None => return Ok(false),
62        };
63
64        let path = PathBuf::from(&path_str);
65
66        // Ensure parent directory exists
67        if let Some(parent) = path.parent() {
68            std::fs::create_dir_all(parent).map_err(|e| SessionError::Io(e.to_string()))?;
69        }
70
71        ArealWriter::save(&self.engine, &path).map_err(|e| SessionError::Save(e.to_string()))?;
72
73        self.engine.mark_clean();
74        Ok(true)
75    }
76
77    /// Load session data from disk if a data path is configured.
78    ///
79    /// Returns `Ok(true)` if data was loaded, `Ok(false)` if no path or file missing.
80    pub fn load(&mut self) -> Result<bool, SessionError> {
81        let path_str = match &self.data_path {
82            Some(p) => p.clone(),
83            None => return Ok(false),
84        };
85
86        let path = PathBuf::from(&path_str);
87        if !path.exists() {
88            return Ok(false);
89        }
90
91        let engine = ArealReader::load(&path).map_err(|e| SessionError::Load(e.to_string()))?;
92
93        self.engine = engine;
94        Ok(true)
95    }
96
97    /// Save if dirty and autosave is enabled.
98    pub fn autosave_if_dirty(&mut self) -> Result<bool, SessionError> {
99        if self.autosave && self.is_dirty() {
100            self.save()
101        } else {
102            Ok(false)
103        }
104    }
105
106    /// Get the workspace identifier.
107    pub fn workspace_id(&self) -> Option<&str> {
108        self.workspace_id.as_deref()
109    }
110}
111
112impl Default for SessionManager {
113    fn default() -> Self {
114        Self::new()
115    }
116}
117
118/// Derive a workspace ID from a canonical file path.
119///
120/// Uses a BLAKE3 hash of the path to produce a stable, short identifier.
121fn derive_workspace_id(path: &str) -> Option<String> {
122    let canonical = match std::fs::canonicalize(Path::new(path)) {
123        Ok(p) => p.to_string_lossy().to_string(),
124        Err(_) => path.to_string(),
125    };
126    let hash = blake3::hash(canonical.as_bytes());
127    let hex = hash.to_hex();
128    Some(hex[..16].to_string())
129}
130
131/// Session management errors.
132#[derive(Debug)]
133pub enum SessionError {
134    /// IO error during save or load.
135    Io(String),
136    /// Error while saving.
137    Save(String),
138    /// Error while loading.
139    Load(String),
140}
141
142impl std::fmt::Display for SessionError {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        match self {
145            SessionError::Io(msg) => write!(f, "session IO error: {}", msg),
146            SessionError::Save(msg) => write!(f, "session save error: {}", msg),
147            SessionError::Load(msg) => write!(f, "session load error: {}", msg),
148        }
149    }
150}
151
152impl std::error::Error for SessionError {}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn test_new_session() {
160        let s = SessionManager::new();
161        assert!(s.data_path.is_none());
162        assert!(s.workspace_id.is_none());
163        assert!(!s.autosave);
164    }
165
166    #[test]
167    fn test_session_with_path() {
168        let s = SessionManager::with_path("/tmp/test.areal".into());
169        assert_eq!(s.data_path.as_deref(), Some("/tmp/test.areal"));
170        assert!(s.workspace_id.is_some());
171    }
172
173    #[test]
174    fn test_autosave_toggle() {
175        let mut s = SessionManager::new();
176        assert!(!s.autosave);
177        s.set_autosave(true);
178        assert!(s.autosave);
179    }
180
181    #[test]
182    fn test_save_no_path_returns_false() {
183        let mut s = SessionManager::new();
184        let result = s.save();
185        assert!(result.is_ok());
186        match result {
187            Ok(saved) => assert!(!saved),
188            Err(_) => panic!("expected Ok(false)"),
189        }
190    }
191
192    #[test]
193    fn test_load_no_path_returns_false() {
194        let mut s = SessionManager::new();
195        let result = s.load();
196        assert!(result.is_ok());
197        match result {
198            Ok(loaded) => assert!(!loaded),
199            Err(_) => panic!("expected Ok(false)"),
200        }
201    }
202
203    #[test]
204    fn test_workspace_id_derivation() {
205        let id = derive_workspace_id("/tmp/test.areal");
206        assert!(id.is_some());
207        match id {
208            Some(ref s) => assert_eq!(s.len(), 16),
209            None => panic!("expected Some"),
210        }
211    }
212
213    #[test]
214    fn test_session_error_display() {
215        let e = SessionError::Save("disk full".into());
216        assert!(e.to_string().contains("disk full"));
217    }
218
219    #[test]
220    fn test_default() {
221        let s = SessionManager::default();
222        assert!(s.data_path.is_none());
223    }
224
225    #[test]
226    fn test_autosave_if_dirty_not_dirty() {
227        let mut s = SessionManager::new();
228        s.set_autosave(true);
229        let result = s.autosave_if_dirty();
230        assert!(result.is_ok());
231        // Engine is not dirty, so no save
232        match result {
233            Ok(saved) => assert!(!saved),
234            Err(_) => panic!("expected Ok(false)"),
235        }
236    }
237}