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 /// Delegates to [`Self::spawn_command`] with the config's path, args,
119 /// and environment.
120 ///
121 /// # Errors
122 /// Returns an error string if no Python interpreter is installed (for `.py`
123 /// scripts) or if the subprocess cannot be spawned.
124 pub fn start_script(&mut self, config: &ScriptConfig) -> Result<ScriptId, String> {
125 self.spawn_command(&config.script_path, &config.args, &config.env_vars)
126 }
127
128 /// Spawn a subprocess by path, resolving the Python interpreter for
129 /// `.py` files (see [`python_interpreter`]).
130 ///
131 /// Shared by tab scripts ([`Self::start_script`]) and the plugin host so
132 /// interpreter resolution has exactly one home. The spawned process is
133 /// registered under a fresh [`ScriptId`].
134 ///
135 /// # Errors
136 /// Returns an error string if no Python interpreter is installed (for
137 /// `.py` paths) or if the subprocess cannot be spawned.
138 pub fn spawn_command(
139 &mut self,
140 script_path: &str,
141 args: &[String],
142 env_vars: &HashMap<String, String>,
143 ) -> Result<ScriptId, String> {
144 let (command, args) = if script_path.ends_with(".py") {
145 let interpreter = python_interpreter().ok_or_else(|| {
146 format!(
147 "no Python interpreter found on PATH (tried {}) to run '{}'",
148 PYTHON_CANDIDATES.join(", "),
149 script_path
150 )
151 })?;
152 let mut full_args = vec![script_path.to_string()];
153 full_args.extend(args.iter().cloned());
154 (interpreter.to_string(), full_args)
155 } else {
156 (script_path.to_string(), args.to_vec())
157 };
158
159 let arg_strs: Vec<&str> = args.iter().map(String::as_str).collect();
160 let process = ScriptProcess::spawn(&command, &arg_strs, env_vars)?;
161
162 let id = self.next_id;
163 self.next_id += 1;
164 self.processes.insert(id, process);
165
166 Ok(id)
167 }
168
169 /// Check if a script with the given ID is still running.
170 ///
171 /// Returns `false` if the script ID is unknown or the process has exited.
172 pub fn is_running(&mut self, id: ScriptId) -> bool {
173 self.processes.get_mut(&id).is_some_and(|p| p.is_running())
174 }
175
176 /// Poll the liveness of a script, classifying an exit as clean or failed.
177 ///
178 /// Returns [`ScriptStatus::Exited { success: true }`] for an unknown id —
179 /// that only happens for an already-cleared slot, which the orchestrator
180 /// never polls.
181 pub fn poll_status(&mut self, id: ScriptId) -> ScriptStatus {
182 match self.processes.get_mut(&id) {
183 Some(p) => p.poll_status(),
184 None => ScriptStatus::Exited { success: true },
185 }
186 }
187
188 /// Send a [`ScriptEvent`] to a specific script by ID.
189 ///
190 /// # Errors
191 /// Returns an error if the script ID is unknown or the write fails.
192 pub fn send_event(&mut self, id: ScriptId, event: &ScriptEvent) -> Result<(), String> {
193 let process = self
194 .processes
195 .get_mut(&id)
196 .ok_or_else(|| format!("No script with id {}", id))?;
197 process.send_event(event)
198 }
199
200 /// Broadcast a [`ScriptEvent`] to all running scripts.
201 ///
202 /// Errors on individual scripts are silently ignored; the event is sent on a
203 /// best-effort basis to all processes.
204 pub fn broadcast_event(&mut self, event: &ScriptEvent) {
205 for process in self.processes.values_mut() {
206 let _ = process.send_event(event);
207 }
208 }
209
210 /// Drain pending [`ScriptCommand`]s from a specific script's stdout buffer.
211 ///
212 /// Returns an empty `Vec` if the script ID is unknown.
213 pub fn read_commands(&self, id: ScriptId) -> Vec<ScriptCommand> {
214 self.processes
215 .get(&id)
216 .map(|p| p.read_commands())
217 .unwrap_or_default()
218 }
219
220 /// Drain pending error lines from a specific script's stderr buffer.
221 ///
222 /// Returns an empty `Vec` if the script ID is unknown.
223 pub fn read_errors(&self, id: ScriptId) -> Vec<String> {
224 self.processes
225 .get(&id)
226 .map(|p| p.read_errors())
227 .unwrap_or_default()
228 }
229
230 /// Stop and remove a specific script by ID.
231 ///
232 /// Also clears the associated panel state and rate-limit tracking.
233 /// Does nothing if the ID is unknown.
234 pub fn stop_script(&mut self, id: ScriptId) {
235 if let Some(mut process) = self.processes.remove(&id) {
236 process.stop();
237 }
238 self.panels.remove(&id);
239 self.write_text_times.remove(&id);
240 self.run_command_times.remove(&id);
241 }
242
243 /// Stop and remove all managed scripts.
244 pub fn stop_all(&mut self) {
245 for (_, mut process) in self.processes.drain() {
246 process.stop();
247 }
248 self.panels.clear();
249 self.write_text_times.clear();
250 self.run_command_times.clear();
251 }
252
253 /// Check whether a `WriteText` command from `id` is within rate limits.
254 ///
255 /// Returns `true` (allowed) if at least `1000 / limit_per_sec` ms have
256 /// elapsed since the last allowed write. Updates the last-write timestamp
257 /// on success. `limit_per_sec == 0` uses the [`DEFAULT_WRITE_TEXT_RATE`].
258 pub fn check_write_text_rate(&mut self, id: ScriptId, limit_per_sec: u32) -> bool {
259 let rate = if limit_per_sec == 0 {
260 DEFAULT_WRITE_TEXT_RATE
261 } else {
262 limit_per_sec
263 };
264 let min_interval_ms = 1000u64 / rate as u64;
265 let now = std::time::Instant::now();
266 if self
267 .write_text_times
268 .get(&id)
269 .is_some_and(|last| (now.duration_since(*last).as_millis() as u64) < min_interval_ms)
270 {
271 return false;
272 }
273 self.write_text_times.insert(id, now);
274 true
275 }
276
277 /// Check whether a `RunCommand` from `id` is within rate limits.
278 ///
279 /// Returns `true` (allowed) if at least `1000 / limit_per_sec` ms have
280 /// elapsed since the last allowed run. Updates the last-run timestamp on
281 /// success. `limit_per_sec == 0` uses the [`DEFAULT_RUN_COMMAND_RATE`].
282 pub fn check_run_command_rate(&mut self, id: ScriptId, limit_per_sec: u32) -> bool {
283 let rate = if limit_per_sec == 0 {
284 DEFAULT_RUN_COMMAND_RATE
285 } else {
286 limit_per_sec
287 };
288 let min_interval_ms = 1000u64 / rate as u64;
289 let now = std::time::Instant::now();
290 if self
291 .run_command_times
292 .get(&id)
293 .is_some_and(|last| (now.duration_since(*last).as_millis() as u64) < min_interval_ms)
294 {
295 return false;
296 }
297 self.run_command_times.insert(id, now);
298 true
299 }
300
301 /// Get the panel state for a script.
302 ///
303 /// Returns `None` if the script ID has no panel set.
304 pub fn get_panel(&self, id: ScriptId) -> Option<&(String, String)> {
305 self.panels.get(&id)
306 }
307
308 /// Set the panel state (title, content) for a script.
309 pub fn set_panel(&mut self, id: ScriptId, title: String, content: String) {
310 self.panels.insert(id, (title, content));
311 }
312
313 /// Clear the panel state for a script.
314 pub fn clear_panel(&mut self, id: ScriptId) {
315 self.panels.remove(&id);
316 }
317
318 /// Get the IDs of all currently managed scripts.
319 pub fn script_ids(&self) -> Vec<ScriptId> {
320 self.processes.keys().copied().collect()
321 }
322}
323
324impl Default for ScriptManager {
325 fn default() -> Self {
326 Self::new()
327 }
328}
329
330impl Drop for ScriptManager {
331 fn drop(&mut self) {
332 self.stop_all();
333 }
334}