1use anyhow::{Context, Result};
9use serde::{Deserialize, Serialize};
10use std::fs;
11use std::path::PathBuf;
12
13#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
21pub struct CliSession {
22 #[serde(skip_serializing_if = "Option::is_none")]
24 pub base_url: Option<String>,
25 #[serde(skip_serializing_if = "Option::is_none")]
27 pub api_key: Option<String>,
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub tenant_id: Option<String>,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub agent_id: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub trajectory_id: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub scope_id: Option<String>,
40}
41
42impl CliSession {
43 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 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 fn path() -> PathBuf {
91 dirs::home_dir()
92 .unwrap_or_else(|| PathBuf::from("."))
93 .join(".cellstate")
94 .join("session.json")
95 }
96
97 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 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 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 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
136pub 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#[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 let data = serde_json::to_string_pretty(&session).unwrap();
182 std::fs::write(&path, &data).unwrap();
183
184 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 assert_eq!(session.base_url, Some("http://example.com:3000".into()));
205 assert_eq!(session.api_key, Some("cst_test_key".into()));
206
207 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 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 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 let empty: CliSession = serde_json::from_str("{}").unwrap();
348 assert_eq!(empty, CliSession::default());
349 }
350}