Skip to main content

dscode_terminal/
manager.rs

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// ── Public types ────────────────────────────────────────────────────────────
13
14#[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/// STATE MACHINE: TerminalInstance
47///
48/// Tracks the lifecycle of a PTY-backed terminal instance.
49///
50/// State Diagram:
51///
52///   Created ──────► Running ──────► ShuttingDown ──────► Closed
53///                      │                                    ▲
54///                      │ (process exit,                     │
55///                      │  read error)                       │
56///                      └────────────────────────────────────┘
57///
58/// Transitions:
59///   Created      -> Running      (start signal sent via start_sender channel)
60///   Running      -> ShuttingDown (shutdown_signal flag set to true)
61///   Running      -> Closed       (PTY process exits, reader thread detects EOF)
62///   ShuttingDown -> Closed       (reader thread sees shutdown flag, exits loop)
63///
64/// Concurrency Invariant:
65///   Terminal I/O uses std::sync::Mutex (not tokio) because PTY operations
66///   are synchronous. The reader thread runs on a dedicated OS thread (not
67///   tokio task). ShutdownSignal uses AtomicBool for lock-free cross-thread
68///   signaling. State transitions should be synchronized through the
69///   TerminalManager's terminals Mutex.
70///
71/// Interruption Table:
72/// ┌──────────────┬──────────────────────────────────────────────────────────┐
73/// │ State        │ What happens on crash/error                             │
74/// ├──────────────┼──────────────────────────────────────────────────────────┤
75/// │ Created      │ PTY allocated but reader waiting for start signal.      │
76/// │              │ If app crashes: PTY master handle dropped, slave exits.  │
77/// │              │ start_sender channel dropped, reader thread unblocks     │
78/// │              │ and exits (recv() returns Err).                          │
79/// ├──────────────┼──────────────────────────────────────────────────────────┤
80/// │ Running      │ Reader thread actively reading PTY output.              │
81/// │              │ If app crashes: PTY handles dropped, OS cleans up.      │
82/// │              │ If shell process exits: reader gets EOF, -> Closed.     │
83/// │              │ If reader thread panics: JoinHandle::join returns Err.  │
84/// │              │ Writer can still try to write (will get error).         │
85/// ├──────────────┼──────────────────────────────────────────────────────────┤
86/// │ ShuttingDown │ Shutdown flag set. Reader thread checking flag each     │
87/// │              │ iteration. May take up to one read timeout to notice.   │
88/// │              │ If reader hangs on blocking read: may not shut down     │
89/// │              │ gracefully. Consider: close PTY master to force EOF.    │
90/// ├──────────────┼──────────────────────────────────────────────────────────┤
91/// │ Closed       │ Reader thread joined. PTY resources released.           │
92/// │              │ Terminal entry removed from TerminalManager map.        │
93/// └──────────────┴──────────────────────────────────────────────────────────┘
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum TerminalState {
96    Created,
97    Running,
98    ShuttingDown,
99    Closed,
100}
101
102// ── Internal helpers ───────────────────────────────────────────────────────
103
104/// Shutdown signal for terminal reader thread.
105struct 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
140// ── Arc-wrapped event sender ───────────────────────────────────────────────
141
142/// Wrapper that allows an [`TerminalEventSender`] to be cheaply cloned
143/// (via `Arc`) so it can be moved into the reader thread.
144pub(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
172// ── Terminal manager ───────────────────────────────────────────────────────
173
174pub 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    // ===== Profile Management =====
192
193    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    // ===== Terminal Creation =====
239
240    pub fn create_terminal_with_options(
241        &self,
242        options: TerminalOptions,
243    ) -> Result<String, String> {
244        // Resolve profile if specified
245        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        // Merge environment variables (options override profile)
263        for (key, value) in options.env {
264            env_vars.insert(key, value);
265        }
266
267        // Determine working directory (options > profile > current)
268        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        // Get next terminal ID
275        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        // Create PTY system and open pair with initial size
286        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        // Create command with args and env
298        let mut cmd = CommandBuilder::new(&shell_cmd);
299        cmd.cwd(&working_dir);
300
301        // Add shell arguments
302        for arg in shell_args {
303            cmd.arg(&arg);
304        }
305
306        // Add environment variables
307        for (key, value) in env_vars {
308            cmd.env(&key, &value);
309        }
310
311        // Spawn the shell
312        let _child = slave
313            .spawn_command(cmd)
314            .map_err(|e| format!("Failed to spawn shell: {}", e))?;
315
316        // Prepare IO handles
317        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        // Create terminal info
326        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        // Create channel for start signal
335        let (start_tx, start_rx) = mpsc::channel();
336
337        // Create shutdown signal
338        let shutdown_signal = ShutdownSignal::new();
339        let shutdown_flag = shutdown_signal.clone_flag();
340
341        // Clone the event sender for the reader thread
342        let sender = self.event_sender.clone();
343
344        // Start reading from PTY in a background thread
345        let terminal_id = id.clone();
346        let reader_handle = thread::spawn(move || {
347            // Wait for ready signal from frontend
348            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                // Check shutdown signal
356                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                        // EOF - terminal closed
364                        info!("Terminal {} closed (EOF)", terminal_id);
365                        sender.send_close(&terminal_id);
366                        break;
367                    }
368                    Ok(n) => {
369                        // Send data to consumer
370                        let data = String::from_utf8_lossy(&buf[0..n]).to_string();
371                        sender.send_output(&terminal_id, &data);
372                    }
373                    Err(e) => {
374                        // Check if this is a normal shutdown
375                        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        // Store terminal instance
387        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            // Signal the reader thread to stop
479            terminal.state = TerminalState::ShuttingDown;
480            terminal.shutdown_signal.signal();
481
482            // Drop the master to close the PTY (this will cause read to return EOF/error)
483            drop(terminal.master);
484
485            // Wait for the reader thread to finish (with timeout)
486            if let Some(handle) = terminal.reader_handle.take() {
487                // Don't block indefinitely - the drop of master should cause the read to return
488                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    /// Close all terminals (for shutdown)
527    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    /// A simple event sender that records events for testing.
561    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        // Should fail if profile already exists
634        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        // Clone flag should also show shutdown
702        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    // ── Serde round-trip tests ──────────────────────────────────────────────────
739
740    #[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        // With #[serde(rename_all = "camelCase")], field names should be camelCase in JSON
800        // "args" stays "args", "env" stays "env", "cwd" stays "cwd",
801        // but verify the JSON is valid and round-trips correctly
802        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    // ── TerminalProfile defaults ────────────────────────────────────────────────
853
854    #[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    // ── TerminalState variants ─────────────────────────────────────────────────
877
878    #[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        // All variants should be distinct
886        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; // Copy, not move
896        assert_eq!(state1, state2);
897    }
898
899    // ── TerminalEventSender trait mock ──────────────────────────────────────────
900
901    #[test]
902    fn test_mock_event_sender() {
903        let sender = TestEventSender::new();
904        // Verify the mock implementation works through the trait
905        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        // Verify the trait object can be boxed (as required by TerminalManager::new)
930        let sender: Box<dyn TerminalEventSender> = Box::new(TestEventSender::new());
931        sender.send_output("t1", "data");
932        sender.send_close("t1");
933    }
934}