syncable_cli/platform/
session.rs

1//! Platform session state management
2//!
3//! Manages the selected platform project/organization context that persists
4//! across CLI sessions. Stored in `~/.syncable/platform-session.json`.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::fs;
9use std::io;
10use std::path::PathBuf;
11
12/// Platform session state - tracks selected project, organization, and environment
13///
14/// This is a separate system from conversation persistence - it tracks
15/// which platform project/org/environment the user has selected for platform operations.
16#[derive(Debug, Clone, Serialize, Deserialize, Default)]
17pub struct PlatformSession {
18    /// Selected platform project UUID
19    pub project_id: Option<String>,
20    /// Human-readable project name
21    pub project_name: Option<String>,
22    /// Organization UUID
23    pub org_id: Option<String>,
24    /// Organization name
25    pub org_name: Option<String>,
26    /// Selected environment UUID
27    pub environment_id: Option<String>,
28    /// Human-readable environment name
29    pub environment_name: Option<String>,
30    /// When the session was last updated
31    pub last_updated: Option<DateTime<Utc>>,
32}
33
34impl PlatformSession {
35    /// Creates a new empty platform session
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    /// Creates a platform session with a selected project
41    pub fn with_project(
42        project_id: String,
43        project_name: String,
44        org_id: String,
45        org_name: String,
46    ) -> Self {
47        Self {
48            project_id: Some(project_id),
49            project_name: Some(project_name),
50            org_id: Some(org_id),
51            org_name: Some(org_name),
52            environment_id: None,
53            environment_name: None,
54            last_updated: Some(Utc::now()),
55        }
56    }
57
58    /// Creates a platform session with a selected project and environment
59    pub fn with_environment(
60        project_id: String,
61        project_name: String,
62        org_id: String,
63        org_name: String,
64        environment_id: String,
65        environment_name: String,
66    ) -> Self {
67        Self {
68            project_id: Some(project_id),
69            project_name: Some(project_name),
70            org_id: Some(org_id),
71            org_name: Some(org_name),
72            environment_id: Some(environment_id),
73            environment_name: Some(environment_name),
74            last_updated: Some(Utc::now()),
75        }
76    }
77
78    /// Clears the selected project and environment
79    pub fn clear(&mut self) {
80        self.project_id = None;
81        self.project_name = None;
82        self.org_id = None;
83        self.org_name = None;
84        self.environment_id = None;
85        self.environment_name = None;
86        self.last_updated = Some(Utc::now());
87    }
88
89    /// Clears only the selected environment (keeps project)
90    pub fn clear_environment(&mut self) {
91        self.environment_id = None;
92        self.environment_name = None;
93        self.last_updated = Some(Utc::now());
94    }
95
96    /// Returns true if a project is currently selected
97    pub fn is_project_selected(&self) -> bool {
98        self.project_id.is_some()
99    }
100
101    /// Returns true if an environment is currently selected
102    pub fn is_environment_selected(&self) -> bool {
103        self.environment_id.is_some()
104    }
105
106    /// Returns the path to the platform session file
107    ///
108    /// Location: `~/.syncable/platform-session.json`
109    pub fn session_path() -> PathBuf {
110        dirs::home_dir()
111            .unwrap_or_else(|| PathBuf::from("."))
112            .join(".syncable")
113            .join("platform-session.json")
114    }
115
116    /// Load platform session from disk
117    ///
118    /// Returns Default if the file doesn't exist or can't be parsed.
119    pub fn load() -> io::Result<Self> {
120        let path = Self::session_path();
121
122        if !path.exists() {
123            return Ok(Self::default());
124        }
125
126        let content = fs::read_to_string(&path)?;
127        serde_json::from_str(&content).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
128    }
129
130    /// Save platform session to disk
131    ///
132    /// Creates `~/.syncable/` directory if it doesn't exist.
133    pub fn save(&self) -> io::Result<()> {
134        let path = Self::session_path();
135
136        // Ensure directory exists (pattern from persistence.rs)
137        if let Some(parent) = path.parent() {
138            fs::create_dir_all(parent)?;
139        }
140
141        let json = serde_json::to_string_pretty(self)?;
142        fs::write(&path, json)?;
143        Ok(())
144    }
145
146    /// Returns a display string for the current context
147    ///
148    /// Format: "[org/project/env]", "[org/project]", or "[no project selected]"
149    pub fn display_context(&self) -> String {
150        match (&self.org_name, &self.project_name, &self.environment_name) {
151            (Some(org), Some(project), Some(env)) => format!("[{}/{}/{}]", org, project, env),
152            (Some(org), Some(project), None) => format!("[{}/{}]", org, project),
153            (None, Some(project), Some(env)) => format!("[{}/{}]", project, env),
154            (None, Some(project), None) => format!("[{}]", project),
155            _ => "[no project selected]".to_string(),
156        }
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use tempfile::tempdir;
164
165    #[test]
166    fn test_new_session_is_empty() {
167        let session = PlatformSession::new();
168        assert!(!session.is_project_selected());
169        assert_eq!(session.display_context(), "[no project selected]");
170    }
171
172    #[test]
173    fn test_with_project() {
174        let session = PlatformSession::with_project(
175            "proj-123".to_string(),
176            "my-project".to_string(),
177            "org-456".to_string(),
178            "my-org".to_string(),
179        );
180
181        assert!(session.is_project_selected());
182        assert_eq!(session.project_id, Some("proj-123".to_string()));
183        assert_eq!(session.display_context(), "[my-org/my-project]");
184    }
185
186    #[test]
187    fn test_clear() {
188        let mut session = PlatformSession::with_project(
189            "proj-123".to_string(),
190            "my-project".to_string(),
191            "org-456".to_string(),
192            "my-org".to_string(),
193        );
194
195        session.clear();
196        assert!(!session.is_project_selected());
197        assert!(session.last_updated.is_some()); // last_updated preserved
198    }
199
200    #[test]
201    fn test_display_context() {
202        // Full context with environment
203        let session = PlatformSession::with_environment(
204            "id".to_string(),
205            "project".to_string(),
206            "oid".to_string(),
207            "org".to_string(),
208            "env-id".to_string(),
209            "prod".to_string(),
210        );
211        assert_eq!(session.display_context(), "[org/project/prod]");
212
213        // Project only (no env)
214        let session = PlatformSession::with_project(
215            "id".to_string(),
216            "project".to_string(),
217            "oid".to_string(),
218            "org".to_string(),
219        );
220        assert_eq!(session.display_context(), "[org/project]");
221
222        // Project only (no org)
223        let session = PlatformSession {
224            project_id: Some("id".to_string()),
225            project_name: Some("project".to_string()),
226            org_id: None,
227            org_name: None,
228            environment_id: None,
229            environment_name: None,
230            last_updated: None,
231        };
232        assert_eq!(session.display_context(), "[project]");
233
234        // No project
235        let session = PlatformSession::new();
236        assert_eq!(session.display_context(), "[no project selected]");
237    }
238
239    #[test]
240    fn test_with_environment() {
241        let session = PlatformSession::with_environment(
242            "proj-123".to_string(),
243            "my-project".to_string(),
244            "org-456".to_string(),
245            "my-org".to_string(),
246            "env-789".to_string(),
247            "production".to_string(),
248        );
249
250        assert!(session.is_project_selected());
251        assert!(session.is_environment_selected());
252        assert_eq!(session.project_id, Some("proj-123".to_string()));
253        assert_eq!(session.environment_id, Some("env-789".to_string()));
254        assert_eq!(session.environment_name, Some("production".to_string()));
255        assert_eq!(session.display_context(), "[my-org/my-project/production]");
256    }
257
258    #[test]
259    fn test_clear_environment() {
260        let mut session = PlatformSession::with_environment(
261            "proj-123".to_string(),
262            "my-project".to_string(),
263            "org-456".to_string(),
264            "my-org".to_string(),
265            "env-789".to_string(),
266            "production".to_string(),
267        );
268
269        assert!(session.is_environment_selected());
270
271        session.clear_environment();
272
273        assert!(session.is_project_selected()); // Project still selected
274        assert!(!session.is_environment_selected()); // Environment cleared
275        assert_eq!(session.display_context(), "[my-org/my-project]");
276    }
277
278    #[test]
279    fn test_is_environment_selected() {
280        let session = PlatformSession::new();
281        assert!(!session.is_environment_selected());
282
283        let session = PlatformSession::with_project(
284            "proj-123".to_string(),
285            "my-project".to_string(),
286            "org-456".to_string(),
287            "my-org".to_string(),
288        );
289        assert!(!session.is_environment_selected());
290
291        let session = PlatformSession::with_environment(
292            "proj-123".to_string(),
293            "my-project".to_string(),
294            "org-456".to_string(),
295            "my-org".to_string(),
296            "env-789".to_string(),
297            "staging".to_string(),
298        );
299        assert!(session.is_environment_selected());
300    }
301
302    #[test]
303    fn test_save_and_load() {
304        // Use a temp directory for testing
305        let temp_dir = tempdir().unwrap();
306        let temp_path = temp_dir.path().join("platform-session.json");
307
308        // Create and save a session
309        let session = PlatformSession::with_project(
310            "proj-789".to_string(),
311            "test-project".to_string(),
312            "org-abc".to_string(),
313            "test-org".to_string(),
314        );
315
316        // Write directly to temp path for testing
317        let json = serde_json::to_string_pretty(&session).unwrap();
318        fs::write(&temp_path, json).unwrap();
319
320        // Read back
321        let content = fs::read_to_string(&temp_path).unwrap();
322        let loaded: PlatformSession = serde_json::from_str(&content).unwrap();
323
324        assert_eq!(loaded.project_id, session.project_id);
325        assert_eq!(loaded.project_name, session.project_name);
326        assert_eq!(loaded.org_id, session.org_id);
327        assert_eq!(loaded.org_name, session.org_name);
328    }
329
330    #[test]
331    fn test_load_missing_file() {
332        // When file doesn't exist, should return default
333        // (This test relies on the actual load() checking path.exists())
334        // We can't easily test this without mocking, so we just verify default behavior
335        let default = PlatformSession::default();
336        assert!(!default.is_project_selected());
337    }
338}