Skip to main content

par_term_scripting/
manager.rs

1//! Per-tab multi-script orchestrator.
2//!
3//! [`ScriptManager`] manages multiple [`ScriptProcess`] instances for a single tab,
4//! providing lifecycle management, event broadcasting, and panel state tracking.
5
6use std::collections::HashMap;
7
8use super::process::{ScriptProcess, ScriptStatus};
9use super::protocol::{ScriptCommand, ScriptEvent};
10use par_term_config::ScriptConfig;
11
12/// Unique identifier for a managed script process.
13pub type ScriptId = u64;
14
15/// Interpreter candidates for `.py` scripts, in priority order.
16///
17/// Direct executables come first on Windows, with the PEP 397 launcher `py`
18/// only as a fallback. `py` is an indirection: it spawns the real interpreter
19/// as a child process and selects the version itself, so the script becomes a
20/// grandchild whose stdio is one hop further away, and it may pick a different
21/// Python than `python` resolves to (on a GitHub runner, 3.14 versus 3.12).
22/// Preferring `py` made the scripting integration tests spawn successfully but
23/// read back zero commands.
24///
25/// `python3` is kept because it exists on Windows CI images even though the
26/// official installer does not create it — and it must stay ahead of `py` for
27/// the same indirection reason.
28#[cfg(windows)]
29const PYTHON_CANDIDATES: &[&str] = &["python", "python3", "py"];
30#[cfg(not(windows))]
31const PYTHON_CANDIDATES: &[&str] = &["python3", "python"];
32
33/// Whether `name` resolves to an executable on `PATH`.
34///
35/// On Windows each `PATHEXT` suffix is tried, since `python` on disk is
36/// `python.exe`.
37fn executable_on_path(name: &str) -> bool {
38    let Some(path) = std::env::var_os("PATH") else {
39        return false;
40    };
41
42    #[cfg(windows)]
43    let extensions: Vec<String> = std::env::var("PATHEXT")
44        .unwrap_or_else(|_| ".EXE;.BAT;.CMD".to_string())
45        .split(';')
46        .filter(|e| !e.is_empty())
47        .map(str::to_string)
48        .collect();
49
50    for dir in std::env::split_paths(&path) {
51        #[cfg(windows)]
52        {
53            for ext in &extensions {
54                if dir.join(format!("{}{}", name, ext)).is_file() {
55                    return true;
56                }
57            }
58        }
59        #[cfg(not(windows))]
60        {
61            if dir.join(name).is_file() {
62                return true;
63            }
64        }
65    }
66    false
67}
68
69/// The interpreter used to run `.py` scripts on this machine.
70///
71/// Returns the first candidate found on `PATH`, or `None` when no Python is
72/// installed, so callers can report that rather than failing to spawn a command
73/// that was never going to exist.
74pub fn python_interpreter() -> Option<&'static str> {
75    PYTHON_CANDIDATES
76        .iter()
77        .copied()
78        .find(|candidate| executable_on_path(candidate))
79}
80
81/// Default maximum `WriteText` writes per second.
82pub const DEFAULT_WRITE_TEXT_RATE: u32 = 10;
83/// Default maximum `RunCommand` executions per second.
84pub const DEFAULT_RUN_COMMAND_RATE: u32 = 1;
85
86/// Manages multiple script subprocess instances for a single tab.
87///
88/// Each script is assigned a unique [`ScriptId`] and can be individually started,
89/// stopped, and communicated with. Supports panel state tracking per script and
90/// event broadcasting to all running scripts.
91pub struct ScriptManager {
92    /// Next ID to assign to a new script process.
93    next_id: ScriptId,
94    /// Map of active script processes keyed by their assigned ID.
95    processes: HashMap<ScriptId, ScriptProcess>,
96    /// Panel state per script: script_id -> (title, content).
97    panels: HashMap<ScriptId, (String, String)>,
98    /// Last `WriteText` dispatch time per script (for rate limiting).
99    write_text_times: HashMap<ScriptId, std::time::Instant>,
100    /// Last `RunCommand` dispatch time per script (for rate limiting).
101    run_command_times: HashMap<ScriptId, std::time::Instant>,
102}
103
104impl ScriptManager {
105    /// Create a new empty `ScriptManager`.
106    pub fn new() -> Self {
107        Self {
108            next_id: 1,
109            processes: HashMap::new(),
110            panels: HashMap::new(),
111            write_text_times: HashMap::new(),
112            run_command_times: HashMap::new(),
113        }
114    }
115
116    /// Start a script subprocess from the given configuration.
117    ///
118    /// If `script_path` ends with `.py`, it is run with the Python interpreter
119    /// resolved by [`python_interpreter`] (the script path is prepended to the
120    /// args). Otherwise, `script_path` is used as the command directly.
121    ///
122    /// Returns the assigned [`ScriptId`] on success.
123    ///
124    /// # Errors
125    /// Returns an error string if no Python interpreter is installed (for `.py`
126    /// scripts) or if the subprocess cannot be spawned.
127    pub fn start_script(&mut self, config: &ScriptConfig) -> Result<ScriptId, String> {
128        let (command, args) = if config.script_path.ends_with(".py") {
129            let interpreter = python_interpreter().ok_or_else(|| {
130                format!(
131                    "no Python interpreter found on PATH (tried {}) to run '{}'",
132                    PYTHON_CANDIDATES.join(", "),
133                    config.script_path
134                )
135            })?;
136            let mut full_args = vec![config.script_path.as_str()];
137            let arg_refs: Vec<&str> = config.args.iter().map(String::as_str).collect();
138            full_args.extend(arg_refs);
139            (
140                interpreter.to_string(),
141                full_args.into_iter().map(String::from).collect::<Vec<_>>(),
142            )
143        } else {
144            let arg_refs: Vec<String> = config.args.to_vec();
145            (config.script_path.clone(), arg_refs)
146        };
147
148        let arg_strs: Vec<&str> = args.iter().map(String::as_str).collect();
149        let process = ScriptProcess::spawn(&command, &arg_strs, &config.env_vars)?;
150
151        let id = self.next_id;
152        self.next_id += 1;
153        self.processes.insert(id, process);
154
155        Ok(id)
156    }
157
158    /// Check if a script with the given ID is still running.
159    ///
160    /// Returns `false` if the script ID is unknown or the process has exited.
161    pub fn is_running(&mut self, id: ScriptId) -> bool {
162        self.processes.get_mut(&id).is_some_and(|p| p.is_running())
163    }
164
165    /// Poll the liveness of a script, classifying an exit as clean or failed.
166    ///
167    /// Returns [`ScriptStatus::Exited { success: true }`] for an unknown id —
168    /// that only happens for an already-cleared slot, which the orchestrator
169    /// never polls.
170    pub fn poll_status(&mut self, id: ScriptId) -> ScriptStatus {
171        match self.processes.get_mut(&id) {
172            Some(p) => p.poll_status(),
173            None => ScriptStatus::Exited { success: true },
174        }
175    }
176
177    /// Send a [`ScriptEvent`] to a specific script by ID.
178    ///
179    /// # Errors
180    /// Returns an error if the script ID is unknown or the write fails.
181    pub fn send_event(&mut self, id: ScriptId, event: &ScriptEvent) -> Result<(), String> {
182        let process = self
183            .processes
184            .get_mut(&id)
185            .ok_or_else(|| format!("No script with id {}", id))?;
186        process.send_event(event)
187    }
188
189    /// Broadcast a [`ScriptEvent`] to all running scripts.
190    ///
191    /// Errors on individual scripts are silently ignored; the event is sent on a
192    /// best-effort basis to all processes.
193    pub fn broadcast_event(&mut self, event: &ScriptEvent) {
194        for process in self.processes.values_mut() {
195            let _ = process.send_event(event);
196        }
197    }
198
199    /// Drain pending [`ScriptCommand`]s from a specific script's stdout buffer.
200    ///
201    /// Returns an empty `Vec` if the script ID is unknown.
202    pub fn read_commands(&self, id: ScriptId) -> Vec<ScriptCommand> {
203        self.processes
204            .get(&id)
205            .map(|p| p.read_commands())
206            .unwrap_or_default()
207    }
208
209    /// Drain pending error lines from a specific script's stderr buffer.
210    ///
211    /// Returns an empty `Vec` if the script ID is unknown.
212    pub fn read_errors(&self, id: ScriptId) -> Vec<String> {
213        self.processes
214            .get(&id)
215            .map(|p| p.read_errors())
216            .unwrap_or_default()
217    }
218
219    /// Stop and remove a specific script by ID.
220    ///
221    /// Also clears the associated panel state and rate-limit tracking.
222    /// Does nothing if the ID is unknown.
223    pub fn stop_script(&mut self, id: ScriptId) {
224        if let Some(mut process) = self.processes.remove(&id) {
225            process.stop();
226        }
227        self.panels.remove(&id);
228        self.write_text_times.remove(&id);
229        self.run_command_times.remove(&id);
230    }
231
232    /// Stop and remove all managed scripts.
233    pub fn stop_all(&mut self) {
234        for (_, mut process) in self.processes.drain() {
235            process.stop();
236        }
237        self.panels.clear();
238        self.write_text_times.clear();
239        self.run_command_times.clear();
240    }
241
242    /// Check whether a `WriteText` command from `id` is within rate limits.
243    ///
244    /// Returns `true` (allowed) if at least `1000 / limit_per_sec` ms have
245    /// elapsed since the last allowed write. Updates the last-write timestamp
246    /// on success. `limit_per_sec == 0` uses the [`DEFAULT_WRITE_TEXT_RATE`].
247    pub fn check_write_text_rate(&mut self, id: ScriptId, limit_per_sec: u32) -> bool {
248        let rate = if limit_per_sec == 0 {
249            DEFAULT_WRITE_TEXT_RATE
250        } else {
251            limit_per_sec
252        };
253        let min_interval_ms = 1000u64 / rate as u64;
254        let now = std::time::Instant::now();
255        if self
256            .write_text_times
257            .get(&id)
258            .is_some_and(|last| (now.duration_since(*last).as_millis() as u64) < min_interval_ms)
259        {
260            return false;
261        }
262        self.write_text_times.insert(id, now);
263        true
264    }
265
266    /// Check whether a `RunCommand` from `id` is within rate limits.
267    ///
268    /// Returns `true` (allowed) if at least `1000 / limit_per_sec` ms have
269    /// elapsed since the last allowed run. Updates the last-run timestamp on
270    /// success. `limit_per_sec == 0` uses the [`DEFAULT_RUN_COMMAND_RATE`].
271    pub fn check_run_command_rate(&mut self, id: ScriptId, limit_per_sec: u32) -> bool {
272        let rate = if limit_per_sec == 0 {
273            DEFAULT_RUN_COMMAND_RATE
274        } else {
275            limit_per_sec
276        };
277        let min_interval_ms = 1000u64 / rate as u64;
278        let now = std::time::Instant::now();
279        if self
280            .run_command_times
281            .get(&id)
282            .is_some_and(|last| (now.duration_since(*last).as_millis() as u64) < min_interval_ms)
283        {
284            return false;
285        }
286        self.run_command_times.insert(id, now);
287        true
288    }
289
290    /// Get the panel state for a script.
291    ///
292    /// Returns `None` if the script ID has no panel set.
293    pub fn get_panel(&self, id: ScriptId) -> Option<&(String, String)> {
294        self.panels.get(&id)
295    }
296
297    /// Set the panel state (title, content) for a script.
298    pub fn set_panel(&mut self, id: ScriptId, title: String, content: String) {
299        self.panels.insert(id, (title, content));
300    }
301
302    /// Clear the panel state for a script.
303    pub fn clear_panel(&mut self, id: ScriptId) {
304        self.panels.remove(&id);
305    }
306
307    /// Get the IDs of all currently managed scripts.
308    pub fn script_ids(&self) -> Vec<ScriptId> {
309        self.processes.keys().copied().collect()
310    }
311}
312
313impl Default for ScriptManager {
314    fn default() -> Self {
315        Self::new()
316    }
317}
318
319impl Drop for ScriptManager {
320    fn drop(&mut self) {
321        self.stop_all();
322    }
323}