Skip to main content

systemprompt_cloud/cli_session/
store.rs

1//! On-disk index of CLI sessions keyed by tenant or `local`.
2
3use 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}