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