cellstate/
session.rs

1//! Persistent CLI session state.
2//!
3//! Stores scope selections (tenant, agent, trajectory, scope) and connection
4//! details so they don't need to be re-specified on every command.
5//!
6//! Session file: `~/.cellstate/session.json`
7
8use anyhow::{Context, Result};
9use serde::{Deserialize, Serialize};
10use std::fs;
11use std::path::PathBuf;
12
13// ============================================================================
14// Session state
15// ============================================================================
16
17/// Persistent CLI session state, stored at `~/.cellstate/session.json`.
18///
19/// Resolution priority for every field: explicit CLI flag > env var > session > default.
20#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
21pub struct CliSession {
22    /// API base URL
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub base_url: Option<String>,
25    /// API key
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub api_key: Option<String>,
28    /// Current tenant scope
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub tenant_id: Option<String>,
31    /// Current agent scope
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub agent_id: Option<String>,
34    /// Current trajectory scope
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub trajectory_id: Option<String>,
37    /// Current scope scope
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub scope_id: Option<String>,
40}
41
42impl CliSession {
43    /// Load session from `~/.cellstate/session.json`.
44    ///
45    /// Returns a default (empty) session if the file doesn't exist or can't be
46    /// parsed — the session is advisory, never a hard gate.
47    pub fn load() -> Result<Self> {
48        let path = Self::path();
49        if !path.exists() {
50            return Ok(Self::default());
51        }
52        let data = match fs::read_to_string(&path) {
53            Ok(d) => d,
54            Err(e) => {
55                eprintln!(
56                    "{} Failed to read session file ({}), using defaults: {e}",
57                    console::style("warning:").yellow().bold(),
58                    path.display()
59                );
60                return Ok(Self::default());
61            }
62        };
63        match serde_json::from_str(&data) {
64            Ok(session) => Ok(session),
65            Err(e) => {
66                eprintln!(
67                    "{} Failed to parse session file ({}), using defaults: {e}",
68                    console::style("warning:").yellow().bold(),
69                    path.display()
70                );
71                Ok(Self::default())
72            }
73        }
74    }
75
76    /// Save session to `~/.cellstate/session.json`, creating the directory if needed.
77    pub fn save(&self) -> Result<()> {
78        let path = Self::path();
79        if let Some(parent) = path.parent() {
80            fs::create_dir_all(parent)
81                .with_context(|| format!("failed to create directory: {}", parent.display()))?;
82        }
83        let data = serde_json::to_string_pretty(self).context("failed to serialize session")?;
84        fs::write(&path, data)
85            .with_context(|| format!("failed to write session file: {}", path.display()))?;
86        Ok(())
87    }
88
89    /// Session file path: `~/.cellstate/session.json`.
90    fn path() -> PathBuf {
91        dirs::home_dir()
92            .unwrap_or_else(|| PathBuf::from("."))
93            .join(".cellstate")
94            .join("session.json")
95    }
96
97    /// Resolve a value with priority: explicit flag > env var > session > default.
98    pub fn resolve(
99        &self,
100        flag: Option<&str>,
101        env_key: &str,
102        session_value: Option<&str>,
103        default: Option<&str>,
104    ) -> Option<String> {
105        flag.map(String::from)
106            .or_else(|| std::env::var(env_key).ok())
107            .or_else(|| session_value.map(String::from))
108            .or_else(|| default.map(String::from))
109    }
110
111    /// Get effective `base_url` (flag > env > session > `http://localhost:3000`).
112    pub fn effective_base_url(&self, flag: Option<&str>) -> String {
113        self.resolve(
114            flag,
115            "CELLSTATE_BASE_URL",
116            self.base_url.as_deref(),
117            Some(crate::http::DEFAULT_BASE_URL),
118        )
119        .unwrap()
120    }
121
122    /// Get effective `api_key` (flag > env > session > None).
123    pub fn effective_api_key(&self, flag: Option<&str>) -> Option<String> {
124        self.resolve(flag, "CELLSTATE_API_KEY", self.api_key.as_deref(), None)
125    }
126
127    /// Clear all scope fields (tenant, agent, trajectory, scope).
128    pub fn clear_scope(&mut self) {
129        self.tenant_id = None;
130        self.agent_id = None;
131        self.trajectory_id = None;
132        self.scope_id = None;
133    }
134}
135
136// ============================================================================
137// UUID validation helper
138// ============================================================================
139
140/// Validate that a string is a valid UUID, returning a human-readable error.
141pub fn validate_uuid(value: &str) -> Result<()> {
142    uuid::Uuid::parse_str(value).with_context(|| format!("'{value}' is not a valid UUID"))?;
143    Ok(())
144}
145
146// ============================================================================
147// Tests
148// ============================================================================
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use std::env;
154
155    #[test]
156    fn test_default_session_is_empty() {
157        let session = CliSession::default();
158        assert!(session.base_url.is_none());
159        assert!(session.api_key.is_none());
160        assert!(session.tenant_id.is_none());
161        assert!(session.agent_id.is_none());
162        assert!(session.trajectory_id.is_none());
163        assert!(session.scope_id.is_none());
164    }
165
166    #[test]
167    fn test_save_load_roundtrip() {
168        let dir = tempfile::tempdir().unwrap();
169        let path = dir.path().join("session.json");
170
171        let session = CliSession {
172            base_url: Some("http://example.com:3000".into()),
173            api_key: Some("cst_test_key".into()),
174            tenant_id: Some("550e8400-e29b-41d4-a716-446655440000".into()),
175            agent_id: Some("6ba7b810-9dad-11d1-80b4-00c04fd430c8".into()),
176            trajectory_id: None,
177            scope_id: None,
178        };
179
180        // Save to custom path
181        let data = serde_json::to_string_pretty(&session).unwrap();
182        std::fs::write(&path, &data).unwrap();
183
184        // Load from custom path
185        let loaded_data = std::fs::read_to_string(&path).unwrap();
186        let loaded: CliSession = serde_json::from_str(&loaded_data).unwrap();
187        assert_eq!(session, loaded);
188    }
189
190    #[test]
191    fn test_clear_scope() {
192        let mut session = CliSession {
193            base_url: Some("http://example.com:3000".into()),
194            api_key: Some("cst_test_key".into()),
195            tenant_id: Some("550e8400-e29b-41d4-a716-446655440000".into()),
196            agent_id: Some("6ba7b810-9dad-11d1-80b4-00c04fd430c8".into()),
197            trajectory_id: Some("f47ac10b-58cc-4372-a567-0e02b2c3d479".into()),
198            scope_id: Some("7c9e6679-7425-40de-944b-e07fc1f90ae7".into()),
199        };
200
201        session.clear_scope();
202
203        // Connection fields preserved
204        assert_eq!(session.base_url, Some("http://example.com:3000".into()));
205        assert_eq!(session.api_key, Some("cst_test_key".into()));
206
207        // Scope fields cleared
208        assert!(session.tenant_id.is_none());
209        assert!(session.agent_id.is_none());
210        assert!(session.trajectory_id.is_none());
211        assert!(session.scope_id.is_none());
212    }
213
214    #[test]
215    fn test_resolve_priority_flag_wins() {
216        let session = CliSession {
217            base_url: Some("http://session.com".into()),
218            ..Default::default()
219        };
220
221        let result = session.resolve(
222            Some("http://flag.com"),
223            "CELLSTATE_TEST_NONEXISTENT_VAR_XYZ",
224            session.base_url.as_deref(),
225            Some("http://default.com"),
226        );
227        assert_eq!(result, Some("http://flag.com".into()));
228    }
229
230    #[test]
231    fn test_resolve_priority_env_over_session() {
232        // Use a unique env var name to avoid test interference
233        let env_key = "CELLSTATE_TEST_RESOLVE_PRIORITY_ENV";
234        env::set_var(env_key, "http://env.com");
235
236        let session = CliSession {
237            base_url: Some("http://session.com".into()),
238            ..Default::default()
239        };
240
241        let result = session.resolve(
242            None,
243            env_key,
244            session.base_url.as_deref(),
245            Some("http://default.com"),
246        );
247        assert_eq!(result, Some("http://env.com".into()));
248
249        env::remove_var(env_key);
250    }
251
252    #[test]
253    fn test_resolve_priority_session_over_default() {
254        let session = CliSession {
255            base_url: Some("http://session.com".into()),
256            ..Default::default()
257        };
258
259        let result = session.resolve(
260            None,
261            "CELLSTATE_TEST_NONEXISTENT_VAR_ABC",
262            session.base_url.as_deref(),
263            Some("http://default.com"),
264        );
265        assert_eq!(result, Some("http://session.com".into()));
266    }
267
268    #[test]
269    fn test_resolve_falls_to_default() {
270        let session = CliSession::default();
271
272        let result = session.resolve(
273            None,
274            "CELLSTATE_TEST_NONEXISTENT_VAR_DEF",
275            None,
276            Some("http://default.com"),
277        );
278        assert_eq!(result, Some("http://default.com".into()));
279    }
280
281    #[test]
282    fn test_resolve_returns_none_when_nothing_set() {
283        let session = CliSession::default();
284
285        let result = session.resolve(None, "CELLSTATE_TEST_NONEXISTENT_VAR_GHI", None, None);
286        assert!(result.is_none());
287    }
288
289    #[test]
290    fn test_effective_base_url_default() {
291        let session = CliSession::default();
292        assert_eq!(
293            session.effective_base_url(None),
294            crate::http::DEFAULT_BASE_URL
295        );
296    }
297
298    #[test]
299    fn test_effective_base_url_flag_override() {
300        let session = CliSession {
301            base_url: Some("http://session.com".into()),
302            ..Default::default()
303        };
304        assert_eq!(
305            session.effective_base_url(Some("http://flag.com")),
306            "http://flag.com"
307        );
308    }
309
310    #[test]
311    fn test_effective_api_key_none_by_default() {
312        let session = CliSession::default();
313        assert!(session.effective_api_key(None).is_none());
314    }
315
316    #[test]
317    fn test_validate_uuid_valid() {
318        assert!(validate_uuid("550e8400-e29b-41d4-a716-446655440000").is_ok());
319        assert!(validate_uuid("6ba7b810-9dad-11d1-80b4-00c04fd430c8").is_ok());
320    }
321
322    #[test]
323    fn test_validate_uuid_invalid() {
324        assert!(validate_uuid("not-a-uuid").is_err());
325        assert!(validate_uuid("").is_err());
326        assert!(validate_uuid("12345").is_err());
327    }
328
329    #[test]
330    fn test_serde_skip_serializing_none() {
331        let session = CliSession {
332            base_url: Some("http://example.com".into()),
333            ..Default::default()
334        };
335        let json = serde_json::to_string(&session).unwrap();
336        // Only base_url should be present, not the None fields
337        assert!(json.contains("base_url"));
338        assert!(!json.contains("api_key"));
339        assert!(!json.contains("tenant_id"));
340    }
341
342    #[test]
343    fn test_load_missing_file_returns_default() {
344        // CliSession::load() reads from ~/.cellstate/session.json which may or
345        // may not exist, but the logic is: if the file doesn't exist, return default.
346        // We test the deserialization path directly.
347        let empty: CliSession = serde_json::from_str("{}").unwrap();
348        assert_eq!(empty, CliSession::default());
349    }
350}