systemprompt_cloud/cli_session/
store.rs1use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use systemprompt_identifiers::TenantId;
13
14use super::{CliSession, LOCAL_SESSION_KEY, SessionKey};
15use crate::error::CloudResult;
16
17const STORE_VERSION: u32 = 1;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct SessionStore {
21 pub version: u32,
22 pub sessions: HashMap<String, CliSession>,
23 pub active_key: Option<String>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub active_profile_name: Option<String>,
26 pub updated_at: DateTime<Utc>,
27}
28
29impl Default for SessionStore {
30 fn default() -> Self {
31 Self::new()
32 }
33}
34
35impl SessionStore {
36 #[must_use]
37 pub fn new() -> Self {
38 Self {
39 version: STORE_VERSION,
40 sessions: HashMap::new(),
41 active_key: None,
42 active_profile_name: None,
43 updated_at: Utc::now(),
44 }
45 }
46
47 #[must_use]
48 pub fn get_valid_session(&self, key: &SessionKey) -> Option<&CliSession> {
49 self.sessions
50 .get(&key.as_storage_key())
51 .filter(|s| !s.is_expired() && s.has_valid_credentials())
52 }
53
54 pub fn get_valid_session_mut(&mut self, key: &SessionKey) -> Option<&mut CliSession> {
55 self.sessions
56 .get_mut(&key.as_storage_key())
57 .filter(|s| !s.is_expired() && s.has_valid_credentials())
58 }
59
60 #[must_use]
61 pub fn get_session(&self, key: &SessionKey) -> Option<&CliSession> {
62 self.sessions.get(&key.as_storage_key())
63 }
64
65 pub fn upsert_session(&mut self, key: &SessionKey, session: CliSession) {
66 self.sessions.insert(key.as_storage_key(), session);
67 self.updated_at = Utc::now();
68 }
69
70 pub fn remove_session(&mut self, key: &SessionKey) -> Option<CliSession> {
71 let storage_key = key.as_storage_key();
72 let removed = self.sessions.remove(&storage_key);
73 if removed.is_some() {
74 self.updated_at = Utc::now();
75 }
76 removed
77 }
78
79 pub fn set_active(&mut self, key: &SessionKey) {
80 self.active_key = Some(key.as_storage_key());
81 self.updated_at = Utc::now();
82 }
83
84 pub fn set_active_with_profile(&mut self, key: &SessionKey, profile_name: &str) {
85 self.active_key = Some(key.as_storage_key());
86 self.active_profile_name = Some(profile_name.to_owned());
87 self.updated_at = Utc::now();
88 }
89
90 pub fn set_active_with_profile_path(
91 &mut self,
92 key: &SessionKey,
93 profile_name: &str,
94 profile_path: PathBuf,
95 ) {
96 self.active_key = Some(key.as_storage_key());
97 self.active_profile_name = Some(profile_name.to_owned());
98
99 if let Some(session) = self.sessions.get_mut(&key.as_storage_key()) {
100 session.update_profile_path(profile_path);
101 }
102
103 self.updated_at = Utc::now();
104 }
105
106 #[must_use]
107 pub fn active_session_key(&self) -> Option<SessionKey> {
108 self.active_key.as_ref().map(|k| {
109 if k == LOCAL_SESSION_KEY {
110 SessionKey::Local
111 } else {
112 k.strip_prefix("tenant_").map_or(SessionKey::Local, |id| {
113 SessionKey::Tenant(TenantId::new(id))
114 })
115 }
116 })
117 }
118
119 #[must_use]
120 pub fn active_session(&self) -> Option<&CliSession> {
121 self.active_session_key()
122 .and_then(|key| self.get_valid_session(&key))
123 }
124
125 pub fn prune_expired(&mut self) -> usize {
126 let expired_keys: Vec<String> = self
127 .sessions
128 .iter()
129 .filter(|(_, s)| s.is_expired())
130 .map(|(k, _)| k.clone())
131 .collect();
132
133 let count = expired_keys.len();
134 for key in &expired_keys {
135 self.sessions.remove(key);
136 }
137
138 if count > 0 {
139 self.updated_at = Utc::now();
140 }
141 count
142 }
143
144 #[must_use]
145 pub fn find_by_profile_name(&self, name: &str) -> Option<&CliSession> {
146 self.sessions
147 .values()
148 .find(|s| s.profile_name.as_str() == name && !s.is_expired())
149 }
150
151 #[must_use]
152 pub fn all_sessions(&self) -> Vec<(&String, &CliSession)> {
153 self.sessions.iter().collect()
154 }
155
156 #[must_use]
157 pub fn len(&self) -> usize {
158 self.sessions.len()
159 }
160
161 #[must_use]
162 pub fn is_empty(&self) -> bool {
163 self.sessions.is_empty()
164 }
165
166 #[must_use]
167 pub fn load(sessions_dir: &Path) -> Option<Self> {
168 let index_path = sessions_dir.join("index.json");
169 let content = match fs::read_to_string(&index_path) {
170 Ok(c) => c,
171 Err(e) => {
172 tracing::debug!(error = %e, "No session store found");
173 return None;
174 },
175 };
176 match serde_json::from_str(&content) {
177 Ok(store) => Some(store),
178 Err(e) => {
179 tracing::warn!(error = %e, "Failed to parse session store");
180 None
181 },
182 }
183 }
184
185 #[expect(
186 clippy::unnecessary_wraps,
187 reason = "Preserves the existing public signature for callers using `?`"
188 )]
189 pub fn load_or_create(sessions_dir: &Path) -> CloudResult<Self> {
190 Ok(Self::load(sessions_dir).unwrap_or_else(Self::new))
191 }
192
193 pub fn save(&self, sessions_dir: &Path) -> CloudResult<()> {
194 fs::create_dir_all(sessions_dir)?;
195
196 let gitignore_path = sessions_dir.join(".gitignore");
197 if !gitignore_path.exists() {
198 fs::write(&gitignore_path, "*\n")?;
199 }
200
201 let index_path = sessions_dir.join("index.json");
202 let content = serde_json::to_string_pretty(self)?;
203 let temp_path = index_path.with_extension("tmp");
204 fs::write(&temp_path, &content)?;
205
206 #[cfg(unix)]
207 {
208 use std::os::unix::fs::PermissionsExt;
209 let mut perms = fs::metadata(&temp_path)?.permissions();
210 perms.set_mode(0o600);
211 fs::set_permissions(&temp_path, perms)?;
212 }
213
214 fs::rename(&temp_path, &index_path)?;
215 Ok(())
216 }
217}