1use std::collections::{HashMap, VecDeque};
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::{Arc, Mutex, MutexGuard, OnceLock, RwLock, Weak};
5
6use sha2::{Digest, Sha256};
7
8use crate::api::{OpenOptions, OpenResult, Operation, OperationResult, RunOptions, TuiTestError};
9use crate::engine::Engine;
10use crate::logger::Logger;
11
12const MAX_COMPLETED_RECORDINGS: usize = 1024;
13
14#[derive(Clone)]
15pub struct Session {
16 name: Arc<str>,
17 engine: Arc<Engine>,
18}
19
20impl Session {
21 pub fn new(name: impl Into<String>) -> Self {
22 let name = name.into();
23 let recording_path = native_recording_path(&name);
24 Self {
25 name: Arc::from(name.as_str()),
26 engine: Arc::new(Engine::new(
27 name,
28 Arc::new(Logger::disabled()),
29 recording_path,
30 )),
31 }
32 }
33
34 pub fn name(&self) -> &str {
35 &self.name
36 }
37
38 pub fn execute(&self, operation: Operation) -> Result<OperationResult, TuiTestError> {
39 self.engine.execute(operation)
40 }
41
42 pub fn open(&self, options: OpenOptions) -> Result<OpenResult, TuiTestError> {
43 match self.execute(Operation::Open(options))? {
44 OperationResult::Open(result) => Ok(result),
45 _ => Err(TuiTestError::internal(
46 "open returned an unexpected result type",
47 )),
48 }
49 }
50
51 pub fn run(&self, options: RunOptions) -> Result<OpenResult, TuiTestError> {
52 match self.execute(Operation::Run(options))? {
53 OperationResult::Open(result) => Ok(result),
54 _ => Err(TuiTestError::internal(
55 "run returned an unexpected result type",
56 )),
57 }
58 }
59
60 pub fn close(&self) -> Result<(), TuiTestError> {
61 self.execute(Operation::Close).map(|_| ())
62 }
63
64 pub fn interrupt(&self) {
65 self.engine.interrupt();
66 }
67
68 pub fn is_open(&self) -> bool {
69 self.engine.is_open()
70 }
71
72 pub fn recording_path(&self) -> &Path {
73 self.engine.recording_path()
74 }
75
76 pub fn recording(&self) -> std::io::Result<String> {
77 self.engine
78 .flush_recording()
79 .map_err(tui_test_error_to_io_error)?;
80 std::fs::read_to_string(self.recording_path())
81 }
82}
83
84#[derive(Clone)]
85pub struct SessionHandle {
86 name: Arc<str>,
87 registry: SessionRegistry,
88}
89
90impl SessionHandle {
91 pub fn name(&self) -> &str {
92 &self.name
93 }
94
95 pub fn execute(&self, operation: Operation) -> Result<OperationResult, TuiTestError> {
96 self.registry.execute(&self.name, operation)
97 }
98
99 pub fn open(&self, options: OpenOptions) -> Result<OpenResult, TuiTestError> {
100 match self.execute(Operation::Open(options))? {
101 OperationResult::Open(result) => Ok(result),
102 _ => Err(TuiTestError::internal(
103 "open returned an unexpected result type",
104 )),
105 }
106 }
107
108 pub fn run(&self, options: RunOptions) -> Result<OpenResult, TuiTestError> {
109 match self.execute(Operation::Run(options))? {
110 OperationResult::Open(result) => Ok(result),
111 _ => Err(TuiTestError::internal(
112 "run returned an unexpected result type",
113 )),
114 }
115 }
116
117 pub fn close(&self) -> Result<(), TuiTestError> {
118 self.registry.close(&self.name)
119 }
120
121 pub fn recording(&self) -> std::io::Result<String> {
122 self.registry.recording(&self.name)
123 }
124}
125
126#[derive(Clone)]
127pub struct SessionRegistry {
128 inner: Arc<RegistryInner>,
129}
130
131struct RegistryInner {
132 sessions: Mutex<HashMap<String, Session>>,
133 recordings: Mutex<CompletedRecordings>,
134 generations: Mutex<HashMap<String, Weak<Mutex<()>>>>,
135 lifecycle: RwLock<()>,
136}
137
138#[derive(Default)]
139struct CompletedRecordings {
140 paths: HashMap<String, PathBuf>,
141 order: VecDeque<String>,
142}
143
144impl Default for SessionRegistry {
145 fn default() -> Self {
146 Self {
147 inner: Arc::new(RegistryInner {
148 sessions: Mutex::new(HashMap::new()),
149 recordings: Mutex::new(CompletedRecordings::default()),
150 generations: Mutex::new(HashMap::new()),
151 lifecycle: RwLock::new(()),
152 }),
153 }
154 }
155}
156
157impl SessionRegistry {
158 pub fn session(&self, name: impl Into<String>) -> SessionHandle {
159 let name = name.into();
160 SessionHandle {
161 name: Arc::from(name),
162 registry: self.clone(),
163 }
164 }
165
166 fn get_or_create_locked(&self, name: String) -> Session {
167 let mut sessions = self.lock_sessions();
168 sessions
169 .entry(name.clone())
170 .or_insert_with(|| Session::new(name))
171 .clone()
172 }
173
174 pub fn execute(
175 &self,
176 name: &str,
177 operation: Operation,
178 ) -> Result<OperationResult, TuiTestError> {
179 let generation = self.generation(name);
180 let _generation = generation
181 .lock()
182 .unwrap_or_else(std::sync::PoisonError::into_inner);
183 match operation {
184 Operation::Open(_) | Operation::Run(_) => {
185 let _lifecycle = self
186 .inner
187 .lifecycle
188 .read()
189 .unwrap_or_else(std::sync::PoisonError::into_inner);
190 self.get_or_create_locked(name.to_string())
191 .execute(operation)
192 }
193 Operation::Close => self.close_locked(name).map(|_| OperationResult::Unit),
194 other => {
195 let session = {
196 let _lifecycle = self
197 .inner
198 .lifecycle
199 .read()
200 .unwrap_or_else(std::sync::PoisonError::into_inner);
201 self.lock_sessions().get(name).cloned()
202 };
203 session.ok_or_else(TuiTestError::no_session)?.execute(other)
204 }
205 }
206 }
207
208 pub fn sessions(&self) -> Vec<String> {
209 let sessions = self
210 .lock_sessions()
211 .iter()
212 .map(|(name, session)| (name.clone(), session.clone()))
213 .collect::<Vec<_>>();
214 let mut names = sessions
215 .into_iter()
216 .filter_map(|(name, session)| session.is_open().then_some(name))
217 .collect::<Vec<_>>();
218 names.sort();
219 names
220 }
221
222 pub fn close(&self, name: &str) -> Result<(), TuiTestError> {
223 let generation = self.generation(name);
224 let _generation = generation
225 .lock()
226 .unwrap_or_else(std::sync::PoisonError::into_inner);
227 self.close_locked(name)
228 }
229
230 pub fn close_all(&self) {
231 let (sessions, removed) = {
232 let _lifecycle = self
233 .inner
234 .lifecycle
235 .write()
236 .unwrap_or_else(std::sync::PoisonError::into_inner);
237 let mut recordings = self.lock_recordings();
238 let sessions = std::mem::take(&mut *self.lock_sessions());
239 let mut removed = Vec::new();
240 for (name, session) in &sessions {
241 let path = session.recording_path();
242 if path.is_file() {
243 removed.extend(Self::cache_recording(
244 &mut recordings,
245 name.clone(),
246 path.to_path_buf(),
247 ));
248 }
249 }
250 (sessions, removed)
251 };
252 Self::remove_recording_files(removed);
253 for session in sessions.values() {
254 session.interrupt();
255 }
256 for session in sessions.into_values() {
257 let _ = session.close();
258 }
259 }
260
261 pub fn recording(&self, name: &str) -> std::io::Result<String> {
262 let generation = self.generation(name);
263 let _generation = generation
264 .lock()
265 .unwrap_or_else(std::sync::PoisonError::into_inner);
266 let (session, completed) = {
267 let _lifecycle = self
268 .inner
269 .lifecycle
270 .read()
271 .unwrap_or_else(std::sync::PoisonError::into_inner);
272 let recordings = self.lock_recordings();
273 let session = self.lock_sessions().get(name).cloned();
274 let completed = recordings.paths.get(name).cloned();
275 (session, completed)
276 };
277 if let Some(session) = session {
278 return session.recording();
279 }
280 let path = completed.ok_or_else(|| {
281 std::io::Error::new(std::io::ErrorKind::NotFound, "unknown native session")
282 })?;
283 std::fs::read_to_string(path)
284 }
285
286 fn close_locked(&self, name: &str) -> Result<(), TuiTestError> {
287 let (session, removed) = {
288 let _lifecycle = self
289 .inner
290 .lifecycle
291 .read()
292 .unwrap_or_else(std::sync::PoisonError::into_inner);
293 let mut recordings = self.lock_recordings();
294 let Some(session) = self.lock_sessions().remove(name) else {
295 return Ok(());
296 };
297 let path = session.recording_path();
298 let removed = if path.is_file() {
299 Self::cache_recording(&mut recordings, name.to_string(), path.to_path_buf())
300 } else {
301 Vec::new()
302 };
303 (session, removed)
304 };
305 Self::remove_recording_files(removed);
306 session.close()
307 }
308
309 fn lock_sessions(&self) -> MutexGuard<'_, HashMap<String, Session>> {
310 self.inner
311 .sessions
312 .lock()
313 .unwrap_or_else(std::sync::PoisonError::into_inner)
314 }
315
316 fn lock_recordings(&self) -> MutexGuard<'_, CompletedRecordings> {
317 self.inner
318 .recordings
319 .lock()
320 .unwrap_or_else(std::sync::PoisonError::into_inner)
321 }
322
323 fn generation(&self, name: &str) -> Arc<Mutex<()>> {
324 let mut generations = self
325 .inner
326 .generations
327 .lock()
328 .unwrap_or_else(std::sync::PoisonError::into_inner);
329 generations.retain(|_, generation| generation.strong_count() > 0);
330 if let Some(generation) = generations.get(name).and_then(Weak::upgrade) {
331 return generation;
332 }
333 let generation = Arc::new(Mutex::new(()));
334 generations.insert(name.to_string(), Arc::downgrade(&generation));
335 generation
336 }
337
338 #[cfg(test)]
339 fn remember_recording(&self, name: String, path: PathBuf) {
340 let removed = Self::cache_recording(&mut self.lock_recordings(), name, path);
341 Self::remove_recording_files(removed);
342 }
343
344 fn cache_recording(
345 recordings: &mut CompletedRecordings,
346 name: String,
347 path: PathBuf,
348 ) -> Vec<PathBuf> {
349 let mut removed = Vec::new();
350 if let Some(previous) = recordings.paths.insert(name.clone(), path.clone()) {
351 if previous != path {
352 removed.push(previous);
353 }
354 recordings.order.retain(|entry| entry != &name);
355 }
356 recordings.order.push_back(name);
357 while recordings.paths.len() > MAX_COMPLETED_RECORDINGS {
358 let Some(oldest) = recordings.order.pop_front() else {
359 break;
360 };
361 if let Some(path) = recordings.paths.remove(&oldest) {
362 removed.push(path);
363 }
364 }
365 removed
366 }
367
368 fn remove_recording_files(paths: Vec<PathBuf>) {
369 for path in paths {
370 let _ = std::fs::remove_file(path);
371 }
372 }
373}
374
375pub fn global_registry() -> &'static SessionRegistry {
376 static REGISTRY: OnceLock<SessionRegistry> = OnceLock::new();
377 REGISTRY.get_or_init(SessionRegistry::default)
378}
379
380fn native_recording_path(name: &str) -> PathBuf {
381 static RECORDING_SEQUENCE: AtomicU64 = AtomicU64::new(0);
382 let digest = format!("{:x}", Sha256::digest(name.as_bytes()));
383 let sequence = RECORDING_SEQUENCE.fetch_add(1, Ordering::Relaxed);
384 dirs::cache_dir()
385 .unwrap_or_else(std::env::temp_dir)
386 .join("tui-test")
387 .join("native")
388 .join(std::process::id().to_string())
389 .join(format!("{}-{sequence}.cast", &digest[..16]))
390}
391
392fn tui_test_error_to_io_error(error: TuiTestError) -> std::io::Error {
393 let kind = if error.kind == crate::api::ErrorKind::NoSession {
394 std::io::ErrorKind::NotFound
395 } else {
396 std::io::ErrorKind::Other
397 };
398 std::io::Error::new(kind, error)
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use crate::api::{ErrorKind, Operation};
405
406 #[test]
407 fn registry_reuses_names_and_lists_only_open_sessions() {
408 let registry = SessionRegistry::default();
409 let first = registry.get_or_create_locked("same".to_string());
410 let second = registry.get_or_create_locked("same".to_string());
411 assert!(Arc::ptr_eq(&first.engine, &second.engine));
412 assert!(registry.sessions().is_empty());
413 }
414
415 #[test]
416 fn closed_session_operations_report_no_session() {
417 let registry = SessionRegistry::default();
418 let error = registry.execute("missing", Operation::State).unwrap_err();
419 assert_eq!(error.kind, ErrorKind::NoSession);
420 }
421
422 #[test]
423 fn completed_recordings_are_bounded() {
424 let registry = SessionRegistry::default();
425 let root =
426 std::env::temp_dir().join(format!("tui-test-recording-cache-{}", std::process::id()));
427 std::fs::create_dir_all(&root).unwrap();
428
429 for index in 0..=MAX_COMPLETED_RECORDINGS {
430 let name = format!("session-{index}");
431 let path = root.join(format!("{index}.cast"));
432 std::fs::write(&path, index.to_string()).unwrap();
433 registry.remember_recording(name, path);
434 }
435
436 assert_eq!(
437 registry.lock_recordings().paths.len(),
438 MAX_COMPLETED_RECORDINGS
439 );
440 assert!(registry.recording("session-0").is_err());
441 assert_eq!(
442 registry
443 .recording(&format!("session-{MAX_COMPLETED_RECORDINGS}"))
444 .unwrap(),
445 MAX_COMPLETED_RECORDINGS.to_string()
446 );
447 assert!(!root.join("0.cast").exists());
448 let _ = std::fs::remove_dir_all(root);
449 }
450
451 #[test]
452 fn missing_operations_do_not_hide_completed_recordings() {
453 let registry = SessionRegistry::default();
454 let path = std::env::temp_dir().join(format!(
455 "tui-test-retained-recording-{}.cast",
456 std::process::id()
457 ));
458 std::fs::write(&path, "retained").unwrap();
459 registry.remember_recording("retained".to_string(), path.clone());
460
461 assert_eq!(
462 registry
463 .execute("retained", Operation::State)
464 .unwrap_err()
465 .kind,
466 ErrorKind::NoSession
467 );
468 assert_eq!(registry.recording("retained").unwrap(), "retained");
469 assert!(registry.sessions().is_empty());
470
471 let _ = std::fs::remove_file(path);
472 }
473
474 #[test]
475 fn closing_never_opened_names_does_not_evict_recordings() {
476 let registry = SessionRegistry::default();
477 let path = std::env::temp_dir().join(format!(
478 "tui-test-valid-recording-{}.cast",
479 std::process::id()
480 ));
481 std::fs::write(&path, "valid").unwrap();
482 registry.remember_recording("valid".to_string(), path.clone());
483
484 for index in 0..=MAX_COMPLETED_RECORDINGS {
485 registry.close(&format!("empty-{index}")).unwrap();
486 }
487
488 assert_eq!(registry.recording("valid").unwrap(), "valid");
489 assert_eq!(registry.lock_recordings().paths.len(), 1);
490 let _ = std::fs::remove_file(path);
491 }
492
493 #[test]
494 fn active_session_does_not_fall_back_to_prior_recording() {
495 let registry = SessionRegistry::default();
496 let path = std::env::temp_dir().join(format!(
497 "tui-test-prior-recording-{}.cast",
498 std::process::id()
499 ));
500 std::fs::write(&path, "prior").unwrap();
501 registry.remember_recording("same".to_string(), path.clone());
502 registry.get_or_create_locked("same".to_string());
503
504 assert!(registry.recording("same").is_err());
505 let _ = std::fs::remove_file(path);
506 }
507}