use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use color_eyre::eyre::{Result, eyre};
use super::event_bus::{Event, EventResult};
#[derive(Debug, Clone)]
pub struct ScriptMeta {
pub name: String,
pub version: Option<String>,
pub description: Option<String>,
pub path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct ConnectionInfo {
pub id: String,
pub label: String,
pub nick: String,
pub connected: bool,
pub user_modes: String,
}
#[derive(Debug, Clone)]
pub struct BufferInfo {
pub id: String,
pub connection_id: String,
pub name: String,
pub buffer_type: String,
pub topic: Option<String>,
pub unread_count: u32,
}
#[derive(Debug, Clone)]
pub struct NickInfo {
pub nick: String,
pub prefix: String,
pub modes: String,
pub away: bool,
}
#[derive(Debug, Clone, Default)]
pub struct ScriptStateSnapshot {
pub active_buffer_id: Option<String>,
pub connections: Vec<ConnectionInfo>,
pub buffers: Vec<BufferInfo>,
pub buffer_nicks: HashMap<String, Vec<NickInfo>>,
pub script_config: HashMap<(String, String), String>,
pub app_config_toml: Option<toml::Value>,
}
pub trait ScriptEngine: Send {
fn extension(&self) -> &'static str;
fn load_script(&mut self, path: &Path, api: &ScriptAPI) -> Result<ScriptMeta>;
fn unload_script(&mut self, name: &str) -> Result<()>;
fn emit(&self, event: &Event) -> EventResult;
fn handle_command(
&self,
name: &str,
args: &[String],
connection_id: Option<&str>,
) -> Option<EventResult>;
fn fire_timer(&self, timer_id: u64);
fn loaded_scripts(&self) -> Vec<ScriptMeta>;
}
type Cb<Args, Ret = ()> = Arc<dyn Fn(Args) -> Ret + Send + Sync>;
pub struct ScriptAPI {
pub say: Cb<(String, String, Option<String>)>,
pub action: Cb<(String, String, Option<String>)>,
pub notice: Cb<(String, String, Option<String>)>,
pub raw: Cb<(String, Option<String>)>,
pub join: Cb<(String, Option<String>, Option<String>)>,
pub part: Cb<(String, Option<String>, Option<String>)>,
pub change_nick: Cb<(String, Option<String>)>,
pub whois: Cb<(String, Option<String>)>,
pub mode: Cb<(String, String, Option<String>)>,
pub kick: Cb<(String, String, Option<String>, Option<String>)>,
pub ctcp: Cb<(String, String, Option<String>, Option<String>)>,
pub add_local_event: Cb<String>,
pub add_buffer_event: Cb<(String, String)>,
pub switch_buffer: Cb<String>,
pub execute_command: Cb<String>,
pub active_buffer_id: Cb<(), Option<String>>,
pub our_nick: Cb<Option<String>, Option<String>>,
pub connection_info: Cb<String, Option<ConnectionInfo>>,
pub connections: Cb<(), Vec<ConnectionInfo>>,
pub buffer_info: Cb<String, Option<BufferInfo>>,
pub buffers: Cb<(), Vec<BufferInfo>>,
pub buffer_nicks: Cb<String, Vec<NickInfo>>,
pub register_command: Cb<(String, String, String)>,
pub unregister_command: Cb<String>,
pub start_timer: Cb<u64, u64>,
pub start_timeout: Cb<u64, u64>,
pub cancel_timer: Cb<u64>,
pub config_get: Cb<(String, String), Option<String>>,
pub config_set: Cb<(String, String, String)>,
pub app_config_get: Cb<String, Option<String>>,
pub log: Cb<(String, String)>,
}
pub struct ScriptManager {
engines: Vec<Box<dyn ScriptEngine>>,
scripts_dir: PathBuf,
}
impl ScriptManager {
pub fn new(scripts_dir: PathBuf) -> Self {
Self {
engines: Vec::new(),
scripts_dir,
}
}
pub fn register_engine(&mut self, engine: Box<dyn ScriptEngine>) {
self.engines.push(engine);
}
pub fn scripts_dir(&self) -> &Path {
&self.scripts_dir
}
pub fn load(&mut self, name_or_path: &str, api: &ScriptAPI) -> Result<ScriptMeta> {
let path = self.resolve_path(name_or_path);
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_string();
let engine = self
.engines
.iter_mut()
.find(|e| e.extension() == ext)
.ok_or_else(|| eyre!("no engine registered for .{ext} files"))?;
engine.load_script(&path, api)
}
pub fn unload(&mut self, name: &str) -> Result<()> {
for engine in &mut self.engines {
if engine.loaded_scripts().iter().any(|s| s.name == name) {
return engine.unload_script(name);
}
}
Err(eyre!("script '{name}' is not loaded"))
}
pub fn reload(&mut self, name: &str, api: &ScriptAPI) -> Result<ScriptMeta> {
let path = self
.loaded_scripts()
.into_iter()
.find(|s| s.name == name)
.map(|s| s.path)
.ok_or_else(|| eyre!("script '{name}' is not loaded"))?;
self.unload(name)?;
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_string();
let engine = self
.engines
.iter_mut()
.find(|e| e.extension() == ext)
.ok_or_else(|| eyre!("no engine for .{ext}"))?;
engine.load_script(&path, api)
}
pub fn emit(&self, event: &Event) -> bool {
for engine in &self.engines {
if engine.emit(event) == EventResult::Suppress {
return true;
}
}
false
}
pub fn handle_command(
&self,
name: &str,
args: &[String],
connection_id: Option<&str>,
) -> Option<EventResult> {
for engine in &self.engines {
if let Some(result) = engine.handle_command(name, args, connection_id) {
return Some(result);
}
}
None
}
pub fn fire_timer(&self, timer_id: u64) {
for engine in &self.engines {
engine.fire_timer(timer_id);
}
}
pub fn loaded_scripts(&self) -> Vec<ScriptMeta> {
self.engines
.iter()
.flat_map(|e| e.loaded_scripts())
.collect()
}
pub fn available_scripts(&self) -> Vec<(String, PathBuf, bool)> {
let loaded: Vec<String> = self
.loaded_scripts()
.iter()
.map(|s| s.name.clone())
.collect();
let mut results = Vec::new();
let Ok(entries) = std::fs::read_dir(&self.scripts_dir) else {
return results;
};
let known_exts: Vec<String> = self
.engines
.iter()
.map(|e| e.extension().to_string())
.collect();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if !known_exts.iter().any(|k| k == ext) {
continue;
}
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let is_loaded = loaded.contains(&name);
results.push((name, path, is_loaded));
}
results.sort_by(|a, b| a.0.cmp(&b.0));
results
}
fn resolve_path(&self, name_or_path: &str) -> PathBuf {
if let Some(rest) = name_or_path.strip_prefix("~/")
&& let Some(home) = dirs::home_dir()
{
return home.join(rest);
}
let p = Path::new(name_or_path);
if p.is_absolute() || name_or_path.starts_with("./") {
return p.to_path_buf();
}
for engine in &self.engines {
let with_ext = self
.scripts_dir
.join(format!("{name_or_path}.{}", engine.extension()));
if with_ext.exists() {
return with_ext;
}
}
self.scripts_dir.join(name_or_path)
}
}