Skip to main content

tui_test/
runtime.rs

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}