1use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
2use std::collections::HashMap;
3use std::io::{Read, Write};
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::mpsc::{self, Sender};
6use std::sync::{Arc, Mutex};
7use std::thread::{self, JoinHandle};
8use tracing::{debug, error, info, instrument};
9
10use crate::TerminalEventSender;
11
12#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
15pub struct TerminalInfo {
16 pub id: String,
17 pub name: String,
18 pub shell: String,
19 pub cwd: String,
20}
21
22#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct TerminalProfile {
25 pub id: String,
26 pub name: String,
27 pub shell: String,
28 pub args: Vec<String>,
29 pub env: HashMap<String, String>,
30 pub cwd: Option<String>,
31 pub icon: Option<String>,
32 pub color: Option<String>,
33}
34
35#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
36#[serde(rename_all = "camelCase")]
37pub struct TerminalOptions {
38 pub name: Option<String>,
39 pub shell_path: Option<String>,
40 pub shell_args: Vec<String>,
41 pub cwd: Option<String>,
42 pub env: HashMap<String, String>,
43 pub profile_id: Option<String>,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum TerminalState {
96 Created,
97 Running,
98 ShuttingDown,
99 Closed,
100}
101
102struct ShutdownSignal {
106 flag: Arc<AtomicBool>,
107}
108
109impl ShutdownSignal {
110 fn new() -> Self {
111 Self {
112 flag: Arc::new(AtomicBool::new(false)),
113 }
114 }
115
116 fn signal(&self) {
117 self.flag.store(true, Ordering::SeqCst);
118 }
119
120 #[allow(dead_code)]
121 fn is_shutdown(&self) -> bool {
122 self.flag.load(Ordering::SeqCst)
123 }
124
125 fn clone_flag(&self) -> Arc<AtomicBool> {
126 Arc::clone(&self.flag)
127 }
128}
129
130pub struct TerminalInstance {
131 pub(crate) info: TerminalInfo,
132 pub(crate) state: TerminalState,
133 writer: Box<dyn Write + Send>,
134 master: Arc<Mutex<Box<dyn MasterPty + Send>>>,
135 start_sender: Option<Sender<()>>,
136 shutdown_signal: ShutdownSignal,
137 reader_handle: Option<JoinHandle<()>>,
138}
139
140pub(crate) struct SharedEventSender {
145 inner: Arc<dyn TerminalEventSender>,
146}
147
148impl SharedEventSender {
149 pub(crate) fn new(sender: Box<dyn TerminalEventSender>) -> Self {
150 Self {
151 inner: Arc::from(sender),
152 }
153 }
154
155 pub(crate) fn clone(&self) -> Self {
156 Self {
157 inner: Arc::clone(&self.inner),
158 }
159 }
160}
161
162impl TerminalEventSender for SharedEventSender {
163 fn send_output(&self, terminal_id: &str, data: &str) {
164 self.inner.send_output(terminal_id, data);
165 }
166
167 fn send_close(&self, terminal_id: &str) {
168 self.inner.send_close(terminal_id);
169 }
170}
171
172pub struct TerminalManager {
175 terminals: Arc<Mutex<HashMap<String, TerminalInstance>>>,
176 profiles: Arc<Mutex<HashMap<String, TerminalProfile>>>,
177 next_id: Arc<Mutex<u32>>,
178 event_sender: SharedEventSender,
179}
180
181impl TerminalManager {
182 pub fn new(event_sender: Box<dyn TerminalEventSender>) -> Self {
183 Self {
184 terminals: Arc::new(Mutex::new(HashMap::new())),
185 profiles: Arc::new(Mutex::new(HashMap::new())),
186 next_id: Arc::new(Mutex::new(1)),
187 event_sender: SharedEventSender::new(event_sender),
188 }
189 }
190
191 pub fn register_profile(&self, profile: TerminalProfile) -> Result<String, String> {
194 let mut profiles = self.profiles.lock().unwrap_or_else(|e| {
195 error!("profiles lock poisoned: {e}");
196 e.into_inner()
197 });
198 let profile_id = profile.id.clone();
199
200 if profiles.contains_key(&profile_id) {
201 return Err(format!("Profile '{}' already exists", profile_id));
202 }
203
204 profiles.insert(profile_id.clone(), profile);
205 Ok(profile_id)
206 }
207
208 pub fn unregister_profile(&self, profile_id: &str) -> Result<(), String> {
209 let mut profiles = self.profiles.lock().unwrap_or_else(|e| {
210 error!("profiles lock poisoned: {e}");
211 e.into_inner()
212 });
213 profiles
214 .remove(profile_id)
215 .ok_or_else(|| format!("Profile '{}' not found", profile_id))?;
216 Ok(())
217 }
218
219 pub fn get_profile(&self, profile_id: &str) -> Result<TerminalProfile, String> {
220 let profiles = self.profiles.lock().unwrap_or_else(|e| {
221 error!("profiles lock poisoned: {e}");
222 e.into_inner()
223 });
224 profiles
225 .get(profile_id)
226 .cloned()
227 .ok_or_else(|| format!("Profile '{}' not found", profile_id))
228 }
229
230 pub fn list_profiles(&self) -> Vec<TerminalProfile> {
231 let profiles = self.profiles.lock().unwrap_or_else(|e| {
232 error!("profiles lock poisoned: {e}");
233 e.into_inner()
234 });
235 profiles.values().cloned().collect()
236 }
237
238 pub fn create_terminal_with_options(
241 &self,
242 options: TerminalOptions,
243 ) -> Result<String, String> {
244 let (shell_cmd, shell_args, mut env_vars, profile_cwd) =
246 if let Some(profile_id) = &options.profile_id {
247 let profile = self.get_profile(profile_id)?;
248 (profile.shell, profile.args, profile.env, profile.cwd)
249 } else {
250 let shell = options.shell_path.clone().unwrap_or_else(|| {
251 std::env::var("SHELL").unwrap_or_else(|_| {
252 if cfg!(target_os = "windows") {
253 "powershell.exe".to_string()
254 } else {
255 "/bin/bash".to_string()
256 }
257 })
258 });
259 (shell, options.shell_args.clone(), HashMap::new(), None)
260 };
261
262 for (key, value) in options.env {
264 env_vars.insert(key, value);
265 }
266
267 let working_dir = options.cwd.or(profile_cwd).unwrap_or_else(|| {
269 std::env::current_dir()
270 .map(|p| p.to_string_lossy().to_string())
271 .unwrap_or_else(|_| "/".to_string())
272 });
273
274 let id = {
276 let mut next = self.next_id.lock().unwrap_or_else(|e| {
277 error!("next_id lock poisoned: {e}");
278 e.into_inner()
279 });
280 let current = *next;
281 *next += 1;
282 format!("terminal-{}", current)
283 };
284
285 let pair = native_pty_system()
287 .openpty(PtySize {
288 rows: 24,
289 cols: 80,
290 pixel_width: 0,
291 pixel_height: 0,
292 })
293 .map_err(|e| format!("Failed to create PTY: {}", e))?;
294
295 let portable_pty::PtyPair { master, slave } = pair;
296
297 let mut cmd = CommandBuilder::new(&shell_cmd);
299 cmd.cwd(&working_dir);
300
301 for arg in shell_args {
303 cmd.arg(&arg);
304 }
305
306 for (key, value) in env_vars {
308 cmd.env(&key, &value);
309 }
310
311 let _child = slave
313 .spawn_command(cmd)
314 .map_err(|e| format!("Failed to spawn shell: {}", e))?;
315
316 let mut reader = master
318 .try_clone_reader()
319 .map_err(|e| format!("Failed to clone reader: {}", e))?;
320 let writer = master
321 .take_writer()
322 .map_err(|e| format!("Failed to get writer: {}", e))?;
323 let master = Arc::new(Mutex::new(master));
324
325 let terminal_name = options.name.unwrap_or_else(|| format!("Terminal {}", id));
327 let info = TerminalInfo {
328 id: id.clone(),
329 name: terminal_name,
330 shell: shell_cmd,
331 cwd: working_dir,
332 };
333
334 let (start_tx, start_rx) = mpsc::channel();
336
337 let shutdown_signal = ShutdownSignal::new();
339 let shutdown_flag = shutdown_signal.clone_flag();
340
341 let sender = self.event_sender.clone();
343
344 let terminal_id = id.clone();
346 let reader_handle = thread::spawn(move || {
347 if start_rx.recv().is_err() {
349 error!("Failed to receive start signal for {}", terminal_id);
350 return;
351 }
352
353 let mut buf = [0u8; 8192];
354 loop {
355 if shutdown_flag.load(Ordering::SeqCst) {
357 info!("Shutdown signal received for {}", terminal_id);
358 break;
359 }
360
361 match reader.read(&mut buf) {
362 Ok(0) => {
363 info!("Terminal {} closed (EOF)", terminal_id);
365 sender.send_close(&terminal_id);
366 break;
367 }
368 Ok(n) => {
369 let data = String::from_utf8_lossy(&buf[0..n]).to_string();
371 sender.send_output(&terminal_id, &data);
372 }
373 Err(e) => {
374 if shutdown_flag.load(Ordering::SeqCst) {
376 debug!("Terminal {} shutdown complete", terminal_id);
377 } else {
378 error!("Error reading from PTY: {}", e);
379 }
380 break;
381 }
382 }
383 }
384 });
385
386 let terminal_instance = TerminalInstance {
388 info: info.clone(),
389 state: TerminalState::Created,
390 writer,
391 master: Arc::clone(&master),
392 start_sender: Some(start_tx),
393 shutdown_signal,
394 reader_handle: Some(reader_handle),
395 };
396
397 {
398 let mut terminals = self.terminals.lock().unwrap_or_else(|e| {
399 error!("terminals lock poisoned: {e}");
400 e.into_inner()
401 });
402 terminals.insert(id.clone(), terminal_instance);
403 }
404
405 Ok(id)
406 }
407
408 #[instrument(skip(self))]
409 pub fn create_terminal(
410 &self,
411 name: Option<String>,
412 shell: Option<String>,
413 cwd: Option<String>,
414 ) -> Result<String, String> {
415 let options = TerminalOptions {
416 name,
417 shell_path: shell,
418 shell_args: vec![],
419 cwd,
420 env: HashMap::new(),
421 profile_id: None,
422 };
423 self.create_terminal_with_options(options)
424 }
425
426 pub fn write_to_terminal(&self, id: &str, data: &str) -> Result<(), String> {
427 let mut terminals = self.terminals.lock().unwrap_or_else(|e| {
428 error!("terminals lock poisoned: {e}");
429 panic!("terminals lock is unrecoverable")
430 });
431 let terminal = terminals
432 .get_mut(id)
433 .ok_or_else(|| format!("Terminal {} not found", id))?;
434
435 terminal
436 .writer
437 .write_all(data.as_bytes())
438 .map_err(|e| format!("Failed to write to terminal: {}", e))?;
439
440 terminal
441 .writer
442 .flush()
443 .map_err(|e| format!("Failed to flush terminal: {}", e))?;
444
445 Ok(())
446 }
447
448 pub fn resize_terminal(&self, id: &str, cols: u16, rows: u16) -> Result<(), String> {
449 let mut terminals = self.terminals.lock().unwrap_or_else(|e| {
450 error!("terminals lock poisoned: {e}");
451 panic!("terminals lock is unrecoverable")
452 });
453 let terminal = terminals
454 .get_mut(id)
455 .ok_or_else(|| format!("Terminal {} not found", id))?;
456
457 let master = terminal.master.lock().unwrap_or_else(|e| {
458 error!("pty master lock poisoned: {e}");
459 e.into_inner()
460 });
461 master
462 .resize(PtySize {
463 rows,
464 cols,
465 pixel_width: 0,
466 pixel_height: 0,
467 })
468 .map_err(|e| format!("Failed to resize terminal: {}", e))
469 }
470
471 #[instrument(skip(self))]
472 pub fn close_terminal(&self, id: &str) -> Result<(), String> {
473 let mut terminals = self.terminals.lock().unwrap_or_else(|e| {
474 error!("terminals lock poisoned: {e}");
475 panic!("terminals lock is unrecoverable")
476 });
477 if let Some(mut terminal) = terminals.remove(id) {
478 terminal.state = TerminalState::ShuttingDown;
480 terminal.shutdown_signal.signal();
481
482 drop(terminal.master);
484
485 if let Some(handle) = terminal.reader_handle.take() {
487 let _ = handle.join();
489 }
490
491 terminal.state = TerminalState::Closed;
492 info!("Terminal {} closed and cleaned up", id);
493 Ok(())
494 } else {
495 Err(format!("Terminal {} not found", id))
496 }
497 }
498
499 pub fn list_terminals(&self) -> Vec<TerminalInfo> {
500 let terminals = self.terminals.lock().unwrap_or_else(|e| {
501 tracing::warn!("Terminals lock poisoned, recovering: {}", e);
502 e.into_inner()
503 });
504 terminals.values().map(|t| t.info.clone()).collect()
505 }
506
507 pub fn start_reading(&self, id: &str) -> Result<(), String> {
508 let mut terminals = self.terminals.lock().unwrap_or_else(|e| {
509 tracing::warn!("Terminals lock poisoned, recovering: {}", e);
510 e.into_inner()
511 });
512 let terminal = terminals
513 .get_mut(id)
514 .ok_or_else(|| format!("Terminal {} not found", id))?;
515
516 if let Some(sender) = terminal.start_sender.take() {
517 sender
518 .send(())
519 .map_err(|e| format!("Failed to send start signal: {}", e))?;
520 terminal.state = TerminalState::Running;
521 }
522
523 Ok(())
524 }
525
526 pub fn close_all(&self) {
528 let mut terminals = self.terminals.lock().unwrap_or_else(|e| {
529 tracing::warn!("Terminals lock poisoned, recovering: {}", e);
530 e.into_inner()
531 });
532 let ids: Vec<String> = terminals.keys().cloned().collect();
533
534 for id in ids {
535 if let Some(mut terminal) = terminals.remove(&id) {
536 terminal.state = TerminalState::ShuttingDown;
537 terminal.shutdown_signal.signal();
538 drop(terminal.master);
539 if let Some(handle) = terminal.reader_handle.take() {
540 let _ = handle.join();
541 }
542 terminal.state = TerminalState::Closed;
543 }
544 }
545
546 info!("All terminals closed");
547 }
548}
549
550impl Drop for TerminalManager {
551 fn drop(&mut self) {
552 self.close_all();
553 }
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559
560 struct TestEventSender {
562 outputs: std::sync::Mutex<Vec<(String, String)>>,
563 closes: std::sync::Mutex<Vec<String>>,
564 }
565
566 impl TestEventSender {
567 fn new() -> Self {
568 Self {
569 outputs: std::sync::Mutex::new(Vec::new()),
570 closes: std::sync::Mutex::new(Vec::new()),
571 }
572 }
573 }
574
575 impl TerminalEventSender for TestEventSender {
576 fn send_output(&self, terminal_id: &str, data: &str) {
577 self.outputs
578 .lock()
579 .unwrap()
580 .push((terminal_id.to_string(), data.to_string()));
581 }
582
583 fn send_close(&self, terminal_id: &str) {
584 self.closes.lock().unwrap().push(terminal_id.to_string());
585 }
586 }
587
588 fn make_manager() -> TerminalManager {
589 TerminalManager::new(Box::new(TestEventSender::new()))
590 }
591
592 #[test]
593 fn test_terminal_manager_creation() {
594 let manager = make_manager();
595 assert!(manager.list_terminals().is_empty());
596 assert!(manager.list_profiles().is_empty());
597 }
598
599 #[test]
600 fn test_list_terminals_on_empty_manager() {
601 let manager = make_manager();
602 let terminals = manager.list_terminals();
603 assert!(terminals.is_empty(), "list_terminals on empty manager should return empty vec");
604 }
605
606 #[test]
607 fn test_close_nonexistent_terminal() {
608 let manager = make_manager();
609 let result = manager.close_terminal("nonexistent-id");
610 assert!(result.is_err(), "Closing a nonexistent terminal should fail");
611 assert!(result.unwrap_err().contains("not found"));
612 }
613
614 #[test]
615 fn test_profile_registration() {
616 let manager = make_manager();
617
618 let profile = TerminalProfile {
619 id: "test-profile".to_string(),
620 name: "Test Shell".to_string(),
621 shell: "/bin/bash".to_string(),
622 args: vec!["-l".to_string()],
623 env: HashMap::new(),
624 cwd: None,
625 icon: None,
626 color: None,
627 };
628
629 let result = manager.register_profile(profile.clone());
630 assert!(result.is_ok());
631 assert_eq!(result.unwrap(), "test-profile");
632
633 let result2 = manager.register_profile(profile);
635 assert!(result2.is_err());
636 }
637
638 #[test]
639 fn test_profile_unregistration() {
640 let manager = make_manager();
641
642 let profile = TerminalProfile {
643 id: "test-profile".to_string(),
644 name: "Test Shell".to_string(),
645 shell: "/bin/bash".to_string(),
646 args: vec![],
647 env: HashMap::new(),
648 cwd: None,
649 icon: None,
650 color: None,
651 };
652
653 manager.register_profile(profile).unwrap();
654 assert!(manager.get_profile("test-profile").is_ok());
655
656 manager.unregister_profile("test-profile").unwrap();
657 assert!(manager.get_profile("test-profile").is_err());
658 }
659
660 #[test]
661 fn test_list_profiles() {
662 let manager = make_manager();
663
664 let profile1 = TerminalProfile {
665 id: "profile1".to_string(),
666 name: "Profile 1".to_string(),
667 shell: "/bin/bash".to_string(),
668 args: vec![],
669 env: HashMap::new(),
670 cwd: None,
671 icon: None,
672 color: None,
673 };
674
675 let profile2 = TerminalProfile {
676 id: "profile2".to_string(),
677 name: "Profile 2".to_string(),
678 shell: "/bin/zsh".to_string(),
679 args: vec![],
680 env: HashMap::new(),
681 cwd: None,
682 icon: None,
683 color: None,
684 };
685
686 manager.register_profile(profile1).unwrap();
687 manager.register_profile(profile2).unwrap();
688
689 let profiles = manager.list_profiles();
690 assert_eq!(profiles.len(), 2);
691 }
692
693 #[test]
694 fn test_shutdown_signal() {
695 let signal = ShutdownSignal::new();
696 assert!(!signal.is_shutdown());
697
698 signal.signal();
699 assert!(signal.is_shutdown());
700
701 let flag = signal.clone_flag();
703 assert!(flag.load(std::sync::atomic::Ordering::SeqCst));
704 }
705
706 #[test]
707 fn test_terminal_info_clone() {
708 let info = TerminalInfo {
709 id: "test-terminal".to_string(),
710 name: "Test Terminal".to_string(),
711 shell: "/bin/bash".to_string(),
712 cwd: "/home/user".to_string(),
713 };
714
715 let cloned = info.clone();
716 assert_eq!(cloned.id, info.id);
717 assert_eq!(cloned.name, info.name);
718 assert_eq!(cloned.shell, info.shell);
719 assert_eq!(cloned.cwd, info.cwd);
720 }
721
722 #[test]
723 fn test_terminal_options_default() {
724 let options = TerminalOptions {
725 name: None,
726 shell_path: None,
727 shell_args: vec![],
728 cwd: None,
729 env: HashMap::new(),
730 profile_id: None,
731 };
732
733 assert!(options.name.is_none());
734 assert!(options.shell_path.is_none());
735 assert!(options.shell_args.is_empty());
736 }
737
738 #[test]
741 fn test_terminal_info_serde_roundtrip() {
742 let info = TerminalInfo {
743 id: "terminal-1".to_string(),
744 name: "My Terminal".to_string(),
745 shell: "/bin/zsh".to_string(),
746 cwd: "/home/user/projects".to_string(),
747 };
748 let json = serde_json::to_string(&info).unwrap();
749 let deserialized: TerminalInfo = serde_json::from_str(&json).unwrap();
750 assert_eq!(deserialized.id, info.id);
751 assert_eq!(deserialized.name, info.name);
752 assert_eq!(deserialized.shell, info.shell);
753 assert_eq!(deserialized.cwd, info.cwd);
754 }
755
756 #[test]
757 fn test_terminal_profile_serde_roundtrip() {
758 let mut env = HashMap::new();
759 env.insert("TERM".to_string(), "xterm-256color".to_string());
760 env.insert("LANG".to_string(), "en_US.UTF-8".to_string());
761
762 let profile = TerminalProfile {
763 id: "zsh-profile".to_string(),
764 name: "ZSH".to_string(),
765 shell: "/bin/zsh".to_string(),
766 args: vec!["-l".to_string(), "-i".to_string()],
767 env: env.clone(),
768 cwd: Some("/home/user".to_string()),
769 icon: Some("terminal".to_string()),
770 color: Some("green".to_string()),
771 };
772 let json = serde_json::to_string(&profile).unwrap();
773 let deserialized: TerminalProfile = serde_json::from_str(&json).unwrap();
774 assert_eq!(deserialized.id, profile.id);
775 assert_eq!(deserialized.name, profile.name);
776 assert_eq!(deserialized.shell, profile.shell);
777 assert_eq!(deserialized.args, profile.args);
778 assert_eq!(deserialized.env, profile.env);
779 assert_eq!(deserialized.cwd, profile.cwd);
780 assert_eq!(deserialized.icon, profile.icon);
781 assert_eq!(deserialized.color, profile.color);
782 }
783
784 #[test]
785 fn test_terminal_profile_serde_camel_case() {
786 let mut env = HashMap::new();
787 env.insert("KEY".to_string(), "val".to_string());
788 let profile = TerminalProfile {
789 id: "p1".to_string(),
790 name: "P1".to_string(),
791 shell: "/bin/bash".to_string(),
792 args: vec![],
793 env,
794 cwd: Some("/tmp".to_string()),
795 icon: Some("terminal".to_string()),
796 color: None,
797 };
798 let json = serde_json::to_string(&profile).unwrap();
799 let deserialized: TerminalProfile = serde_json::from_str(&json).unwrap();
803 assert_eq!(deserialized.id, profile.id);
804 assert_eq!(deserialized.name, profile.name);
805 assert_eq!(deserialized.shell, profile.shell);
806 assert_eq!(deserialized.args, profile.args);
807 assert_eq!(deserialized.env, profile.env);
808 assert_eq!(deserialized.cwd, profile.cwd);
809 assert_eq!(deserialized.icon, profile.icon);
810 assert_eq!(deserialized.color, profile.color);
811 }
812
813 #[test]
814 fn test_terminal_options_serde_roundtrip() {
815 let mut env = HashMap::new();
816 env.insert("FOO".to_string(), "bar".to_string());
817
818 let options = TerminalOptions {
819 name: Some("My Term".to_string()),
820 shell_path: Some("/bin/fish".to_string()),
821 shell_args: vec!["-l".to_string()],
822 cwd: Some("/home/user".to_string()),
823 env: env.clone(),
824 profile_id: Some("fish-profile".to_string()),
825 };
826 let json = serde_json::to_string(&options).unwrap();
827 let deserialized: TerminalOptions = serde_json::from_str(&json).unwrap();
828 assert_eq!(deserialized.name, options.name);
829 assert_eq!(deserialized.shell_path, options.shell_path);
830 assert_eq!(deserialized.shell_args, options.shell_args);
831 assert_eq!(deserialized.cwd, options.cwd);
832 assert_eq!(deserialized.env, options.env);
833 assert_eq!(deserialized.profile_id, options.profile_id);
834 }
835
836 #[test]
837 fn test_terminal_options_serde_camel_case() {
838 let options = TerminalOptions {
839 name: None,
840 shell_path: Some("/bin/bash".to_string()),
841 shell_args: vec![],
842 cwd: None,
843 env: HashMap::new(),
844 profile_id: Some("default".to_string()),
845 };
846 let json = serde_json::to_string(&options).unwrap();
847 assert!(json.contains("shellPath"), "expected camelCase 'shellPath' in JSON: {}", json);
848 assert!(json.contains("shellArgs"), "expected camelCase 'shellArgs' in JSON: {}", json);
849 assert!(json.contains("profileId"), "expected camelCase 'profileId' in JSON: {}", json);
850 }
851
852 #[test]
855 fn test_terminal_profile_default_values() {
856 let profile = TerminalProfile {
857 id: "default".to_string(),
858 name: "Default".to_string(),
859 shell: "/bin/bash".to_string(),
860 args: vec![],
861 env: HashMap::new(),
862 cwd: None,
863 icon: None,
864 color: None,
865 };
866 assert_eq!(profile.id, "default");
867 assert_eq!(profile.name, "Default");
868 assert_eq!(profile.shell, "/bin/bash");
869 assert!(profile.args.is_empty());
870 assert!(profile.env.is_empty());
871 assert!(profile.cwd.is_none());
872 assert!(profile.icon.is_none());
873 assert!(profile.color.is_none());
874 }
875
876 #[test]
879 fn test_terminal_state_variants() {
880 assert_eq!(TerminalState::Created, TerminalState::Created);
881 assert_eq!(TerminalState::Running, TerminalState::Running);
882 assert_eq!(TerminalState::ShuttingDown, TerminalState::ShuttingDown);
883 assert_eq!(TerminalState::Closed, TerminalState::Closed);
884
885 assert_ne!(TerminalState::Created, TerminalState::Running);
887 assert_ne!(TerminalState::Running, TerminalState::ShuttingDown);
888 assert_ne!(TerminalState::ShuttingDown, TerminalState::Closed);
889 assert_ne!(TerminalState::Created, TerminalState::Closed);
890 }
891
892 #[test]
893 fn test_terminal_state_copy_equality() {
894 let state1 = TerminalState::Running;
895 let state2 = state1; assert_eq!(state1, state2);
897 }
898
899 #[test]
902 fn test_mock_event_sender() {
903 let sender = TestEventSender::new();
904 let sender_ref: &dyn TerminalEventSender = &sender;
906 sender_ref.send_output("term-1", "hello world");
907 sender_ref.send_close("term-1");
908
909 assert_eq!(sender.outputs.lock().unwrap().len(), 1);
910 assert_eq!(sender.outputs.lock().unwrap()[0], ("term-1".to_string(), "hello world".to_string()));
911 assert_eq!(sender.closes.lock().unwrap().len(), 1);
912 assert_eq!(sender.closes.lock().unwrap()[0], "term-1".to_string());
913 }
914
915 #[test]
916 fn test_event_sender_multiple_outputs() {
917 let sender = TestEventSender::new();
918 sender.send_output("t1", "line1");
919 sender.send_output("t1", "line2");
920 sender.send_output("t2", "other");
921 sender.send_close("t1");
922
923 assert_eq!(sender.outputs.lock().unwrap().len(), 3);
924 assert_eq!(sender.closes.lock().unwrap().len(), 1);
925 }
926
927 #[test]
928 fn test_event_sender_boxed_dyn() {
929 let sender: Box<dyn TerminalEventSender> = Box::new(TestEventSender::new());
931 sender.send_output("t1", "data");
932 sender.send_close("t1");
933 }
934}