par-term-scripting 0.1.17

Scripting and observer system for par-term terminal emulator
Documentation
//! Per-tab multi-script orchestrator.
//!
//! [`ScriptManager`] manages multiple [`ScriptProcess`] instances for a single tab,
//! providing lifecycle management, event broadcasting, and panel state tracking.

use std::collections::HashMap;

use super::process::{ScriptProcess, ScriptStatus};
use super::protocol::{ScriptCommand, ScriptEvent};
use par_term_config::ScriptConfig;

/// Unique identifier for a managed script process.
pub type ScriptId = u64;

/// Interpreter candidates for `.py` scripts, in priority order.
///
/// Direct executables come first on Windows, with the PEP 397 launcher `py`
/// only as a fallback. `py` is an indirection: it spawns the real interpreter
/// as a child process and selects the version itself, so the script becomes a
/// grandchild whose stdio is one hop further away, and it may pick a different
/// Python than `python` resolves to (on a GitHub runner, 3.14 versus 3.12).
/// Preferring `py` made the scripting integration tests spawn successfully but
/// read back zero commands.
///
/// `python3` is kept because it exists on Windows CI images even though the
/// official installer does not create it — and it must stay ahead of `py` for
/// the same indirection reason.
#[cfg(windows)]
const PYTHON_CANDIDATES: &[&str] = &["python", "python3", "py"];
#[cfg(not(windows))]
const PYTHON_CANDIDATES: &[&str] = &["python3", "python"];

/// Whether `name` resolves to an executable on `PATH`.
///
/// On Windows each `PATHEXT` suffix is tried, since `python` on disk is
/// `python.exe`.
fn executable_on_path(name: &str) -> bool {
    let Some(path) = std::env::var_os("PATH") else {
        return false;
    };

    #[cfg(windows)]
    let extensions: Vec<String> = std::env::var("PATHEXT")
        .unwrap_or_else(|_| ".EXE;.BAT;.CMD".to_string())
        .split(';')
        .filter(|e| !e.is_empty())
        .map(str::to_string)
        .collect();

    for dir in std::env::split_paths(&path) {
        #[cfg(windows)]
        {
            for ext in &extensions {
                if dir.join(format!("{}{}", name, ext)).is_file() {
                    return true;
                }
            }
        }
        #[cfg(not(windows))]
        {
            if dir.join(name).is_file() {
                return true;
            }
        }
    }
    false
}

/// The interpreter used to run `.py` scripts on this machine.
///
/// Returns the first candidate found on `PATH`, or `None` when no Python is
/// installed, so callers can report that rather than failing to spawn a command
/// that was never going to exist.
pub fn python_interpreter() -> Option<&'static str> {
    PYTHON_CANDIDATES
        .iter()
        .copied()
        .find(|candidate| executable_on_path(candidate))
}

/// Default maximum `WriteText` writes per second.
pub const DEFAULT_WRITE_TEXT_RATE: u32 = 10;
/// Default maximum `RunCommand` executions per second.
pub const DEFAULT_RUN_COMMAND_RATE: u32 = 1;

/// Manages multiple script subprocess instances for a single tab.
///
/// Each script is assigned a unique [`ScriptId`] and can be individually started,
/// stopped, and communicated with. Supports panel state tracking per script and
/// event broadcasting to all running scripts.
pub struct ScriptManager {
    /// Next ID to assign to a new script process.
    next_id: ScriptId,
    /// Map of active script processes keyed by their assigned ID.
    processes: HashMap<ScriptId, ScriptProcess>,
    /// Panel state per script: script_id -> (title, content).
    panels: HashMap<ScriptId, (String, String)>,
    /// Last `WriteText` dispatch time per script (for rate limiting).
    write_text_times: HashMap<ScriptId, std::time::Instant>,
    /// Last `RunCommand` dispatch time per script (for rate limiting).
    run_command_times: HashMap<ScriptId, std::time::Instant>,
}

impl ScriptManager {
    /// Create a new empty `ScriptManager`.
    pub fn new() -> Self {
        Self {
            next_id: 1,
            processes: HashMap::new(),
            panels: HashMap::new(),
            write_text_times: HashMap::new(),
            run_command_times: HashMap::new(),
        }
    }

    /// Start a script subprocess from the given configuration.
    ///
    /// Delegates to [`Self::spawn_command`] with the config's path, args,
    /// and environment.
    ///
    /// # Errors
    /// Returns an error string if no Python interpreter is installed (for `.py`
    /// scripts) or if the subprocess cannot be spawned.
    pub fn start_script(&mut self, config: &ScriptConfig) -> Result<ScriptId, String> {
        self.spawn_command(&config.script_path, &config.args, &config.env_vars)
    }

    /// Spawn a subprocess by path, resolving the Python interpreter for
    /// `.py` files (see [`python_interpreter`]).
    ///
    /// Shared by tab scripts ([`Self::start_script`]) and the plugin host so
    /// interpreter resolution has exactly one home. The spawned process is
    /// registered under a fresh [`ScriptId`].
    ///
    /// # Errors
    /// Returns an error string if no Python interpreter is installed (for
    /// `.py` paths) or if the subprocess cannot be spawned.
    pub fn spawn_command(
        &mut self,
        script_path: &str,
        args: &[String],
        env_vars: &HashMap<String, String>,
    ) -> Result<ScriptId, String> {
        let (command, args) = if script_path.ends_with(".py") {
            let interpreter = python_interpreter().ok_or_else(|| {
                format!(
                    "no Python interpreter found on PATH (tried {}) to run '{}'",
                    PYTHON_CANDIDATES.join(", "),
                    script_path
                )
            })?;
            let mut full_args = vec![script_path.to_string()];
            full_args.extend(args.iter().cloned());
            (interpreter.to_string(), full_args)
        } else {
            (script_path.to_string(), args.to_vec())
        };

        let arg_strs: Vec<&str> = args.iter().map(String::as_str).collect();
        let process = ScriptProcess::spawn(&command, &arg_strs, env_vars)?;

        let id = self.next_id;
        self.next_id += 1;
        self.processes.insert(id, process);

        Ok(id)
    }

    /// Check if a script with the given ID is still running.
    ///
    /// Returns `false` if the script ID is unknown or the process has exited.
    pub fn is_running(&mut self, id: ScriptId) -> bool {
        self.processes.get_mut(&id).is_some_and(|p| p.is_running())
    }

    /// Poll the liveness of a script, classifying an exit as clean or failed.
    ///
    /// Returns [`ScriptStatus::Exited { success: true }`] for an unknown id —
    /// that only happens for an already-cleared slot, which the orchestrator
    /// never polls.
    pub fn poll_status(&mut self, id: ScriptId) -> ScriptStatus {
        match self.processes.get_mut(&id) {
            Some(p) => p.poll_status(),
            None => ScriptStatus::Exited { success: true },
        }
    }

    /// Send a [`ScriptEvent`] to a specific script by ID.
    ///
    /// # Errors
    /// Returns an error if the script ID is unknown or the write fails.
    pub fn send_event(&mut self, id: ScriptId, event: &ScriptEvent) -> Result<(), String> {
        let process = self
            .processes
            .get_mut(&id)
            .ok_or_else(|| format!("No script with id {}", id))?;
        process.send_event(event)
    }

    /// Broadcast a [`ScriptEvent`] to all running scripts.
    ///
    /// Errors on individual scripts are silently ignored; the event is sent on a
    /// best-effort basis to all processes.
    pub fn broadcast_event(&mut self, event: &ScriptEvent) {
        for process in self.processes.values_mut() {
            let _ = process.send_event(event);
        }
    }

    /// Drain pending [`ScriptCommand`]s from a specific script's stdout buffer.
    ///
    /// Returns an empty `Vec` if the script ID is unknown.
    pub fn read_commands(&self, id: ScriptId) -> Vec<ScriptCommand> {
        self.processes
            .get(&id)
            .map(|p| p.read_commands())
            .unwrap_or_default()
    }

    /// Drain pending error lines from a specific script's stderr buffer.
    ///
    /// Returns an empty `Vec` if the script ID is unknown.
    pub fn read_errors(&self, id: ScriptId) -> Vec<String> {
        self.processes
            .get(&id)
            .map(|p| p.read_errors())
            .unwrap_or_default()
    }

    /// Stop and remove a specific script by ID.
    ///
    /// Also clears the associated panel state and rate-limit tracking.
    /// Does nothing if the ID is unknown.
    pub fn stop_script(&mut self, id: ScriptId) {
        if let Some(mut process) = self.processes.remove(&id) {
            process.stop();
        }
        self.panels.remove(&id);
        self.write_text_times.remove(&id);
        self.run_command_times.remove(&id);
    }

    /// Stop and remove all managed scripts.
    pub fn stop_all(&mut self) {
        for (_, mut process) in self.processes.drain() {
            process.stop();
        }
        self.panels.clear();
        self.write_text_times.clear();
        self.run_command_times.clear();
    }

    /// Check whether a `WriteText` command from `id` is within rate limits.
    ///
    /// Returns `true` (allowed) if at least `1000 / limit_per_sec` ms have
    /// elapsed since the last allowed write. Updates the last-write timestamp
    /// on success. `limit_per_sec == 0` uses the [`DEFAULT_WRITE_TEXT_RATE`].
    pub fn check_write_text_rate(&mut self, id: ScriptId, limit_per_sec: u32) -> bool {
        let rate = if limit_per_sec == 0 {
            DEFAULT_WRITE_TEXT_RATE
        } else {
            limit_per_sec
        };
        let min_interval_ms = 1000u64 / rate as u64;
        let now = std::time::Instant::now();
        if self
            .write_text_times
            .get(&id)
            .is_some_and(|last| (now.duration_since(*last).as_millis() as u64) < min_interval_ms)
        {
            return false;
        }
        self.write_text_times.insert(id, now);
        true
    }

    /// Check whether a `RunCommand` from `id` is within rate limits.
    ///
    /// Returns `true` (allowed) if at least `1000 / limit_per_sec` ms have
    /// elapsed since the last allowed run. Updates the last-run timestamp on
    /// success. `limit_per_sec == 0` uses the [`DEFAULT_RUN_COMMAND_RATE`].
    pub fn check_run_command_rate(&mut self, id: ScriptId, limit_per_sec: u32) -> bool {
        let rate = if limit_per_sec == 0 {
            DEFAULT_RUN_COMMAND_RATE
        } else {
            limit_per_sec
        };
        let min_interval_ms = 1000u64 / rate as u64;
        let now = std::time::Instant::now();
        if self
            .run_command_times
            .get(&id)
            .is_some_and(|last| (now.duration_since(*last).as_millis() as u64) < min_interval_ms)
        {
            return false;
        }
        self.run_command_times.insert(id, now);
        true
    }

    /// Get the panel state for a script.
    ///
    /// Returns `None` if the script ID has no panel set.
    pub fn get_panel(&self, id: ScriptId) -> Option<&(String, String)> {
        self.panels.get(&id)
    }

    /// Set the panel state (title, content) for a script.
    pub fn set_panel(&mut self, id: ScriptId, title: String, content: String) {
        self.panels.insert(id, (title, content));
    }

    /// Clear the panel state for a script.
    pub fn clear_panel(&mut self, id: ScriptId) {
        self.panels.remove(&id);
    }

    /// Get the IDs of all currently managed scripts.
    pub fn script_ids(&self) -> Vec<ScriptId> {
        self.processes.keys().copied().collect()
    }
}

impl Default for ScriptManager {
    fn default() -> Self {
        Self::new()
    }
}

impl Drop for ScriptManager {
    fn drop(&mut self) {
        self.stop_all();
    }
}