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 #[serde(skip_serializing_if = "Option::is_none")]
39 pub active_plan_id: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub plan_title: Option<String>,
42}
43
44fn cache_dir() -> Option<PathBuf> {
46 directories::BaseDirs::new().map(|b| b.home_dir().join(".savecontext").join("status-cache"))
47}
48
49fn sanitize_key(key: &str) -> Option<String> {
51 let sanitized: String = key
52 .trim()
53 .chars()
54 .map(|c| {
55 if c == '/' || c == '\\' || c == ':' || c == '*' || c == '?'
56 || c == '"' || c == '<' || c == '>' || c == '|' || c.is_whitespace() {
57 '_'
58 } else {
59 c
60 }
61 })
62 .take(100)
63 .collect();
64
65 if sanitized.is_empty() {
66 None
67 } else {
68 Some(sanitized)
69 }
70}
71
72fn find_tty_from_ancestors() -> Option<String> {
79 let mut current_pid = std::process::id().to_string();
80
81 for _ in 0..5 {
82 if let Ok(output) = Command::new("ps")
84 .args(["-o", "tty=", "-p", ¤t_pid])
85 .output()
86 {
87 if output.status.success() {
88 let tty = String::from_utf8_lossy(&output.stdout).trim().to_string();
89 if !tty.is_empty() && tty != "?" && tty != "??" {
90 return Some(tty);
91 }
92 }
93 }
94
95 let Ok(output) = Command::new("ps")
97 .args(["-o", "ppid=", "-p", ¤t_pid])
98 .output()
99 else {
100 break;
101 };
102
103 if !output.status.success() {
104 break;
105 }
106
107 let ppid = String::from_utf8_lossy(&output.stdout).trim().to_string();
108 if ppid.is_empty() || ppid == "0" || ppid == "1" || ppid == current_pid {
109 break;
110 }
111 current_pid = ppid;
112 }
113
114 None
115}
116
117pub fn get_status_key() -> Option<String> {
122 if let Ok(key) = std::env::var("SAVECONTEXT_STATUS_KEY") {
124 if !key.is_empty() {
125 return sanitize_key(&key);
126 }
127 }
128
129 if let Some(tty) = find_tty_from_ancestors() {
131 return sanitize_key(&format!("tty-{}", tty));
132 }
133
134 if let Ok(term_id) = std::env::var("TERM_SESSION_ID") {
136 if !term_id.is_empty() {
137 return sanitize_key(&format!("term-{}", term_id));
138 }
139 }
140
141 if let Ok(iterm_id) = std::env::var("ITERM_SESSION_ID") {
143 if !iterm_id.is_empty() {
144 return sanitize_key(&format!("iterm-{}", iterm_id));
145 }
146 }
147
148 None
150}
151
152pub fn read_status_cache() -> Option<StatusCacheEntry> {
160 let key = get_status_key()?;
161 let cache_path = cache_dir()?.join(format!("{}.json", key));
162
163 if !cache_path.exists() {
164 return None;
165 }
166
167 let content = fs::read_to_string(&cache_path).ok()?;
168 let entry: StatusCacheEntry = serde_json::from_str(&content).ok()?;
169
170 let now = SystemTime::now()
172 .duration_since(UNIX_EPOCH)
173 .unwrap_or(Duration::ZERO)
174 .as_millis() as u64;
175
176 if now.saturating_sub(entry.timestamp) > CACHE_TTL_MS {
177 let _ = fs::remove_file(&cache_path);
179 return None;
180 }
181
182 Some(entry)
183}
184
185pub fn current_session_id() -> Option<String> {
190 read_status_cache().map(|e| e.session_id)
191}
192
193pub fn write_status_cache(entry: &StatusCacheEntry) -> bool {
201 let Some(key) = get_status_key() else {
202 return false;
203 };
204
205 let Some(dir) = cache_dir() else {
206 return false;
207 };
208
209 if let Err(_) = fs::create_dir_all(&dir) {
211 return false;
212 }
213
214 let file_path = dir.join(format!("{key}.json"));
215 let temp_path = dir.join(format!("{key}.json.tmp"));
216
217 let Ok(json) = serde_json::to_string_pretty(entry) else {
219 return false;
220 };
221
222 let result = (|| -> std::io::Result<()> {
224 {
225 let mut opts = fs::OpenOptions::new();
226 opts.write(true).create(true).truncate(true);
227 #[cfg(unix)]
228 opts.mode(0o600);
229 let mut file = opts.open(&temp_path)?;
230 file.write_all(json.as_bytes())?;
231 file.flush()?;
232 }
233 fs::rename(&temp_path, &file_path)?;
234 Ok(())
235 })();
236
237 result.is_ok()
238}
239
240pub fn clear_status_cache() -> bool {
245 let Some(key) = get_status_key() else {
246 return false;
247 };
248
249 let Some(dir) = cache_dir() else {
250 return false;
251 };
252
253 let file_path = dir.join(format!("{key}.json"));
254
255 if file_path.exists() {
256 fs::remove_file(&file_path).is_ok()
257 } else {
258 true }
260}
261
262pub fn bind_session_to_terminal(
267 session_id: &str,
268 session_name: &str,
269 project_path: &str,
270 status: &str,
271) -> bool {
272 let now = SystemTime::now()
273 .duration_since(UNIX_EPOCH)
274 .unwrap_or(Duration::ZERO)
275 .as_millis() as u64;
276
277 let entry = StatusCacheEntry {
278 session_id: session_id.to_string(),
279 session_name: session_name.to_string(),
280 project_path: project_path.to_string(),
281 timestamp: now,
282 provider: Some("cli".to_string()),
283 item_count: None,
284 session_status: Some(status.to_string()),
285 active_plan_id: None,
286 plan_title: None,
287 };
288
289 write_status_cache(&entry)
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 #[test]
297 fn test_sanitize_key() {
298 assert_eq!(sanitize_key("simple"), Some("simple".to_string()));
299 assert_eq!(sanitize_key("with/slash"), Some("with_slash".to_string()));
300 assert_eq!(sanitize_key("with spaces"), Some("with_spaces".to_string()));
301 assert_eq!(sanitize_key(""), None);
302 assert_eq!(sanitize_key(" "), None);
303 }
304
305 #[test]
306 fn test_cache_dir() {
307 let dir = cache_dir();
308 assert!(dir.is_some());
309 let path = dir.unwrap();
310 assert!(path.ends_with("status-cache"));
311 }
312}