sc/config/
status_cache.rs1use serde::{Deserialize, Serialize};
16use std::fs;
17use std::io::Write;
18#[cfg(unix)]
19use std::os::unix::fs::OpenOptionsExt;
20use std::path::PathBuf;
21use std::process::Command;
22use std::time::{Duration, SystemTime, UNIX_EPOCH};
23
24const CACHE_TTL_MS: u64 = 2 * 60 * 60 * 1000;
26
27#[derive(Debug, Deserialize, Serialize)]
29#[serde(rename_all = "camelCase")]
30pub struct StatusCacheEntry {
31 pub session_id: String,
32 pub session_name: String,
33 pub project_path: String,
34 pub timestamp: u64,
35 pub provider: Option<String>,
36 pub item_count: Option<u32>,
37 pub session_status: Option<String>,
38}
39
40fn cache_dir() -> Option<PathBuf> {
42 directories::BaseDirs::new().map(|b| b.home_dir().join(".savecontext").join("status-cache"))
43}
44
45fn sanitize_key(key: &str) -> Option<String> {
47 let sanitized: String = key
48 .trim()
49 .chars()
50 .map(|c| {
51 if c == '/' || c == '\\' || c == ':' || c == '*' || c == '?'
52 || c == '"' || c == '<' || c == '>' || c == '|' || c.is_whitespace() {
53 '_'
54 } else {
55 c
56 }
57 })
58 .take(100)
59 .collect();
60
61 if sanitized.is_empty() {
62 None
63 } else {
64 Some(sanitized)
65 }
66}
67
68fn find_tty_from_ancestors() -> Option<String> {
75 let mut current_pid = std::process::id().to_string();
76
77 for _ in 0..5 {
78 if let Ok(output) = Command::new("ps")
80 .args(["-o", "tty=", "-p", ¤t_pid])
81 .output()
82 {
83 if output.status.success() {
84 let tty = String::from_utf8_lossy(&output.stdout).trim().to_string();
85 if !tty.is_empty() && tty != "?" && tty != "??" {
86 return Some(tty);
87 }
88 }
89 }
90
91 let Ok(output) = Command::new("ps")
93 .args(["-o", "ppid=", "-p", ¤t_pid])
94 .output()
95 else {
96 break;
97 };
98
99 if !output.status.success() {
100 break;
101 }
102
103 let ppid = String::from_utf8_lossy(&output.stdout).trim().to_string();
104 if ppid.is_empty() || ppid == "0" || ppid == "1" || ppid == current_pid {
105 break;
106 }
107 current_pid = ppid;
108 }
109
110 None
111}
112
113pub fn get_status_key() -> Option<String> {
118 if let Ok(key) = std::env::var("SAVECONTEXT_STATUS_KEY") {
120 if !key.is_empty() {
121 return sanitize_key(&key);
122 }
123 }
124
125 if let Some(tty) = find_tty_from_ancestors() {
127 return sanitize_key(&format!("tty-{}", tty));
128 }
129
130 if let Ok(term_id) = std::env::var("TERM_SESSION_ID") {
132 if !term_id.is_empty() {
133 return sanitize_key(&format!("term-{}", term_id));
134 }
135 }
136
137 if let Ok(iterm_id) = std::env::var("ITERM_SESSION_ID") {
139 if !iterm_id.is_empty() {
140 return sanitize_key(&format!("iterm-{}", iterm_id));
141 }
142 }
143
144 None
146}
147
148pub fn read_status_cache() -> Option<StatusCacheEntry> {
156 let key = get_status_key()?;
157 let cache_path = cache_dir()?.join(format!("{}.json", key));
158
159 if !cache_path.exists() {
160 return None;
161 }
162
163 let content = fs::read_to_string(&cache_path).ok()?;
164 let entry: StatusCacheEntry = serde_json::from_str(&content).ok()?;
165
166 let now = SystemTime::now()
168 .duration_since(UNIX_EPOCH)
169 .unwrap_or(Duration::ZERO)
170 .as_millis() as u64;
171
172 if now.saturating_sub(entry.timestamp) > CACHE_TTL_MS {
173 let _ = fs::remove_file(&cache_path);
175 return None;
176 }
177
178 Some(entry)
179}
180
181pub fn current_session_id() -> Option<String> {
186 read_status_cache().map(|e| e.session_id)
187}
188
189pub fn write_status_cache(entry: &StatusCacheEntry) -> bool {
197 let Some(key) = get_status_key() else {
198 return false;
199 };
200
201 let Some(dir) = cache_dir() else {
202 return false;
203 };
204
205 if let Err(_) = fs::create_dir_all(&dir) {
207 return false;
208 }
209
210 let file_path = dir.join(format!("{key}.json"));
211 let temp_path = dir.join(format!("{key}.json.tmp"));
212
213 let Ok(json) = serde_json::to_string_pretty(entry) else {
215 return false;
216 };
217
218 let result = (|| -> std::io::Result<()> {
220 {
221 let mut opts = fs::OpenOptions::new();
222 opts.write(true).create(true).truncate(true);
223 #[cfg(unix)]
224 opts.mode(0o600);
225 let mut file = opts.open(&temp_path)?;
226 file.write_all(json.as_bytes())?;
227 file.flush()?;
228 }
229 fs::rename(&temp_path, &file_path)?;
230 Ok(())
231 })();
232
233 result.is_ok()
234}
235
236pub fn clear_status_cache() -> bool {
241 let Some(key) = get_status_key() else {
242 return false;
243 };
244
245 let Some(dir) = cache_dir() else {
246 return false;
247 };
248
249 let file_path = dir.join(format!("{key}.json"));
250
251 if file_path.exists() {
252 fs::remove_file(&file_path).is_ok()
253 } else {
254 true }
256}
257
258pub fn bind_session_to_terminal(
263 session_id: &str,
264 session_name: &str,
265 project_path: &str,
266 status: &str,
267) -> bool {
268 let now = SystemTime::now()
269 .duration_since(UNIX_EPOCH)
270 .unwrap_or(Duration::ZERO)
271 .as_millis() as u64;
272
273 let entry = StatusCacheEntry {
274 session_id: session_id.to_string(),
275 session_name: session_name.to_string(),
276 project_path: project_path.to_string(),
277 timestamp: now,
278 provider: Some("cli".to_string()),
279 item_count: None,
280 session_status: Some(status.to_string()),
281 };
282
283 write_status_cache(&entry)
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn test_sanitize_key() {
292 assert_eq!(sanitize_key("simple"), Some("simple".to_string()));
293 assert_eq!(sanitize_key("with/slash"), Some("with_slash".to_string()));
294 assert_eq!(sanitize_key("with spaces"), Some("with_spaces".to_string()));
295 assert_eq!(sanitize_key(""), None);
296 assert_eq!(sanitize_key(" "), None);
297 }
298
299 #[test]
300 fn test_cache_dir() {
301 let dir = cache_dir();
302 assert!(dir.is_some());
303 let path = dir.unwrap();
304 assert!(path.ends_with("status-cache"));
305 }
306}