diff --git a/CLAUDE.md b/CLAUDE.md index 63abf26..4de1ad2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,8 @@ Special-character rendering for the display lives in `graphics/special_char.rs` Plugins are Lua scripts (mlua, `lua54` vendored) returning a table `M`. The engine calls lifecycle/event hooks by name: `on_load`, `on_unload`, `on_serial_connect`/`on_serial_disconnect`/`on_serial_send`/`on_serial_recv` (and `on_rtt_*` equivalents), plus any `M.` the user calls via `!plugin `. Plugins reach back into the app through the `bridge`/`method_call` gates. See `plugins/README.md` for the plugin developer guide. +- **Installed plugins** (`plugin/installed.rs`, issue #36): `!plugin install ` loads a plugin *and* records its name in a TOML manifest `/scope/plugins/installed.toml` (next to the staged `.lua` files); `!plugin list` prints the set. The engine owns the manifest: `PluginEngineCommand::{InstallPlugin,ListInstalledPlugins}` handle the commands, and `load_installed_plugins` (called once at the top of `task_async`, before the command loop) auto-loads each installed plugin from its staged copy, logging per plugin like `!plugin load`. Install persists only after a successful load, dedupes, and treats a missing/malformed manifest or a broken entry as logged-and-skipped (never fatal — it's program-managed state, not the user's `config.toml`). Uninstall is issue #37. + ## Logging `infra/logger.rs` provides a channel-based logger; each task gets a clone tagged with its source name. Use the `error!`, `warning!`, `success!`, `info!` macros — messages fan in to the Graphics task for display. diff --git a/README.md b/README.md index 9d3759c..d713fea 100644 --- a/README.md +++ b/README.md @@ -241,9 +241,11 @@ Anything typed on the command bar that starts with `!` is a command. A line with | `!mute ` | Hide received messages matching the regex `` (the inverse of `!filter`, like `grep -v`). Call with no argument to mute every received message (a warning is logged). Display-only — the session record keeps every message. | | `!send_file ` | Stream a file to the target over the active interface. | | `!log ` | Set the log level. `` is `system` (`sys`) or a plugin name; `` is one of `debug`, `info`, `success`, `warning`, `error`. | -| `!plugin load ` | Load a Lua plugin from a file. | +| `!plugin load ` | Load a Lua plugin from a file (for this session only). | | `!plugin reload ` | Reload a plugin from a file. | -| `!plugin unload ` | Unload a plugin by name. | +| `!plugin install ` | Load a plugin **and** install it, so every subsequent `Scope` session auto-loads it. | +| `!plugin list` | List the installed (auto-loaded) plugins. | +| `!plugin unload ` | Unload a plugin by name (for this session; does not uninstall). | | `! [args...]` | Call a command exported by a loaded plugin (see [Plugins](#plugins)). | ## Keyboard & Mouse Shortcuts @@ -352,7 +354,7 @@ end return M ``` -Load a plugin with `!plugin load ` (and `!plugin reload ` / `!plugin unload ` to reload or remove it). To call one of your plugin's commands, type `!` followed by the plugin name, the command name and its arguments — for example `!echo hello`. Inside a plugin you can react to lifecycle and I/O events (`on_load`, `on_unload`, `on_serial_recv`/`on_serial_send`, `on_rtt_recv`/`on_rtt_send`) and interact with `Scope` and the target: connect/disconnect, send data, read RTT memory, print messages, run shell commands and more. For the full guide see the [Plugins Developer Guide](plugins/README.md). +Load a plugin with `!plugin load ` (and `!plugin reload ` / `!plugin unload ` to reload or unload it). To keep a plugin across sessions, use `!plugin install `: it loads the plugin now and records it so every subsequent `Scope` session auto-loads it at start-up (see `!plugin list` to review the installed set). To call one of your plugin's commands, type `!` followed by the plugin name, the command name and its arguments — for example `!echo hello`. Inside a plugin you can react to lifecycle and I/O events (`on_load`, `on_unload`, `on_serial_recv`/`on_serial_send`, `on_rtt_recv`/`on_rtt_send`) and interact with `Scope` and the target: connect/disconnect, send data, read RTT memory, print messages, run shell commands and more. For the full guide see the [Plugins Developer Guide](plugins/README.md). ![Plugin usage](videos/011_plugin/video.gif) diff --git a/plugins/README.md b/plugins/README.md index 24bb34d..580e02c 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -34,6 +34,8 @@ return M To execute this plugin you need to load it into the `Scope`. With the `Scope` open, you could type `!plugin load hello.lua`. If you remember the analogy of the chip and the handheld tool, then you need to insert the chip into handheld to it works. Likewise, we need to "insert" (or load) our plugin into our "handheld" (or the `Scope` program). With the plugin loaded, all messages will be replied. The replied message will have the following suffix: `Hello,`. +`!plugin load` lasts only for the current session. If a plugin is part of your everyday workflow, **install** it instead with `!plugin install hello.lua`: this loads it now and records it so every subsequent `Scope` session auto-loads it at start-up (the name is stored in `installed.toml`, next to the staged plugins in `/scope/plugins/`). Use `!plugin list` to see what's installed. + ## Hello, World Let's break down each line of the sample above. At the first line we're importing the `serial` functions from the scope standard library. We need this import to interact with the current connected serial port. diff --git a/src/inputs/inputs_task.rs b/src/inputs/inputs_task.rs index 8c86440..de1e3bb 100644 --- a/src/inputs/inputs_task.rs +++ b/src/inputs/inputs_task.rs @@ -1321,35 +1321,50 @@ impl InputsTask { } } "plugin" => { - if command_line_split.len() < 3 { + let Some(subcommand) = command_line_split.get(1).map(String::as_str) else { error!( private.logger, - "Insufficient arguments for \"!plugin\" command" + "Please, use \"load\", \"reload\", \"install\", \"list\" or \"unload\" subcommands" ); return; + }; + + // `list` is the only subcommand that takes no argument. + if subcommand == "list" { + let _ = private + .plugin_engine_cmd_sender + .send(PluginEngineCommand::ListInstalledPlugins); + return; } - let command = command_line_split[1].as_str(); + let Some(arg) = command_line_split.get(2).cloned() else { + error!( + private.logger, + "Insufficient arguments for \"!plugin {}\" command", subcommand + ); + return; + }; - match command { + match subcommand { "load" | "reload" => { - let filepath = command_line_split[2].clone(); - let _ = private .plugin_engine_cmd_sender - .send(PluginEngineCommand::LoadPlugin { filepath }); + .send(PluginEngineCommand::LoadPlugin { filepath: arg }); + } + "install" => { + let _ = private + .plugin_engine_cmd_sender + .send(PluginEngineCommand::InstallPlugin { filepath: arg }); } "unload" => { - let plugin_name = command_line_split[2].clone(); - let _ = private .plugin_engine_cmd_sender - .send(PluginEngineCommand::UnloadPlugin { plugin_name }); + .send(PluginEngineCommand::UnloadPlugin { plugin_name: arg }); } _ => { error!( private.logger, - "Invalid command. Please, choose one of these options: load, reload, unload" + "Invalid command. Please, choose one of these options: load, reload, install, list, unload" ); } } diff --git a/src/plugin/engine.rs b/src/plugin/engine.rs index cbeb582..acb27e5 100644 --- a/src/plugin/engine.rs +++ b/src/plugin/engine.rs @@ -1,10 +1,11 @@ use super::{ Plugin, PluginUnloadMode, bridge::{PluginEngineGate, PluginMethodCallGate}, + installed::Installed, messages::{self, PluginExternalRequest, PluginMethodMessage, PluginResponse}, }; use crate::{ - error, + error, info, infra::{ logger::{LogLevel, Logger}, messages::TimedBytes, @@ -89,6 +90,13 @@ pub enum PluginEngineCommand { LoadPlugin { filepath: String, }, + /// Load the plugin now (like `LoadPlugin`) and record it in the persistent + /// manifest so every subsequent session auto-loads it (issue #36). + InstallPlugin { + filepath: String, + }, + /// Log the names of the currently installed (auto-loaded) plugins. + ListInstalledPlugins, UnloadPlugin { plugin_name: String, }, @@ -171,6 +179,18 @@ impl PluginEngine { let mut rtt_read_reqs = vec![]; let err_regex = Regex::new(r#".*: \[string ".*"]:"#).unwrap(); + // Auto-load every installed plugin (issue #36) before entering the + // command loop, so a user's persistent set is ready without manual + // `!plugin load`s. Each load logs per plugin; a missing/broken installed + // plugin is reported and skipped rather than aborting start-up. + Self::load_installed_plugins( + &mut engine_gate, + &mut plugin_list, + &private.logger, + &err_regex, + ) + .await; + 'plugin_engine_loop: loop { if let Ok(cmd) = cmd_receiver.try_recv() { match cmd { @@ -227,6 +247,75 @@ impl PluginEngine { Err(err) => error!(private.logger, "{}", err_regex.replace(&err, "")), } } + PluginEngineCommand::InstallPlugin { filepath } => { + let Some(plugin_name) = Self::get_plugin_name(&filepath) else { + continue 'plugin_engine_loop; + }; + + let dir = plugins_dir(); + + // Read the manifest up front so a corrupt file surfaces + // here instead of silently dropping the install. + let mut installed = match Installed::load(&dir) { + Ok(installed) => installed, + Err(err) => { + error!(private.logger, "{}", err); + continue 'plugin_engine_loop; + } + }; + + // Make sure the plugin is loaded this session. If it + // isn't yet, load it now and bail on failure so a broken + // plugin is never persisted into the auto-load set. + if !plugin_list.contains_key(&plugin_name) { + let Ok(source) = PathBuf::from_str(&filepath); + + if let Err(err) = Self::load_plugin( + engine_gate.new_method_call_gate(), + Arc::new(plugin_name.clone()), + source, + &mut plugin_list, + private.logger.clone(), + ) + .await + { + error!(private.logger, "{}", err_regex.replace(&err, "")); + continue 'plugin_engine_loop; + } + } + + // Persist. `add` dedupes, so re-installing is idempotent. + let newly_added = installed.add(&plugin_name); + if let Err(err) = installed.save(&dir) { + error!(private.logger, "{}", err); + continue 'plugin_engine_loop; + } + + if newly_added { + success!(private.logger, "Plugin \"{}\" installed", plugin_name); + } else { + success!( + private.logger, + "Plugin \"{}\" is already installed", + plugin_name + ); + } + } + PluginEngineCommand::ListInstalledPlugins => { + match Installed::load(&plugins_dir()) { + Ok(installed) if installed.names().is_empty() => { + info!(private.logger, "No plugins installed"); + } + Ok(installed) => { + info!( + private.logger, + "Installed plugins: {}", + installed.names().join(", ") + ); + } + Err(err) => error!(private.logger, "{}", err), + } + } PluginEngineCommand::UnloadPlugin { plugin_name } => { let Some(plugin) = plugin_list.get_mut(&plugin_name) else { error!(private.logger, "Plugin \"{}\" not loaded", plugin_name); @@ -823,6 +912,44 @@ impl PluginEngine { .map(|filename| filename.to_string()) } + /// Load every plugin recorded in the install manifest, staging each from its + /// copy in the plugins directory. A malformed manifest, a missing file, or a + /// broken plugin is logged and skipped so a bad entry can't stop scope from + /// starting. Each successful load logs per plugin, mirroring `!plugin load`. + async fn load_installed_plugins( + engine_gate: &mut PluginEngineGate, + plugin_list: &mut HashMap, Plugin>, + logger: &Logger, + err_regex: &Regex, + ) { + let dir = plugins_dir(); + let installed = match Installed::load(&dir) { + Ok(installed) => installed, + Err(err) => { + error!(logger, "{}", err); + return; + } + }; + + for name in installed.names() { + // Load from the staged copy in the plugins directory; `load_plugin` + // re-stages (a no-op when source == dest) and loads it. + let source = dir.join(format!("{}.lua", name)); + match Self::load_plugin( + engine_gate.new_method_call_gate(), + Arc::new(name.clone()), + source, + plugin_list, + logger.clone(), + ) + .await + { + Ok(_) => success!(logger, "Plugin \"{}\" loaded", name), + Err(err) => error!(logger, "{}", err_regex.replace(&err, "")), + } + } + } + async fn load_plugin( gate: PluginMethodCallGate, plugin_name: Arc, diff --git a/src/plugin/installed.rs b/src/plugin/installed.rs new file mode 100644 index 0000000..0189a99 --- /dev/null +++ b/src/plugin/installed.rs @@ -0,0 +1,168 @@ +//! The persistent list of *installed* plugins (issue #36). +//! +//! Loading a plugin with `!plugin load` is per-session; **installing** one with +//! `!plugin install` records its name in a small manifest so every subsequent +//! `scope` session auto-loads it at start-up. The manifest is a TOML file, +//! `installed.toml`, kept in the same directory scope stages loaded plugins +//! into (`/scope/plugins/`), next to the copied `.lua` files and the +//! bundled standard library. +//! +//! ```toml +//! plugins = ["analytics", "auto_test"] +//! ``` +//! +//! This is program-managed state (not the user-owned `config.toml`), so a +//! missing manifest is simply "nothing installed" and never an error. A +//! malformed manifest *is* reported, but the caller decides whether that is +//! fatal (at start-up it is logged and skipped so a corrupt file can't brick the +//! app; an explicit `!plugin install` surfaces it). + +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +const MANIFEST_FILE: &str = "installed.toml"; + +/// The set of installed plugin names, persisted to `installed.toml`. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct Installed { + /// Installed plugin names (without the `.lua` extension), in install order. + #[serde(default)] + plugins: Vec, +} + +impl Installed { + /// The manifest path inside the plugins directory `dir`. + fn path(dir: &Path) -> PathBuf { + dir.join(MANIFEST_FILE) + } + + /// Read the manifest from `dir`. A missing file yields an empty list; an + /// unreadable or malformed file is an error. + pub fn load(dir: &Path) -> Result { + let path = Self::path(dir); + let contents = match std::fs::read_to_string(&path) { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok(Installed::default()); + } + Err(err) => { + return Err(format!( + "Cannot read plugin manifest at {:?}: {}", + path, err + )); + } + }; + + toml::from_str(&contents) + .map_err(|err| format!("Cannot parse plugin manifest at {:?}: {}", path, err)) + } + + /// The installed plugin names, in install order. + pub fn names(&self) -> &[String] { + &self.plugins + } + + /// Whether `name` is already installed. + pub fn contains(&self, name: &str) -> bool { + self.plugins.iter().any(|p| p == name) + } + + /// Record `name` as installed. Returns `true` if it was newly added, or + /// `false` if it was already present (no duplicate is created). + pub fn add(&mut self, name: &str) -> bool { + if self.contains(name) { + return false; + } + self.plugins.push(name.to_string()); + true + } + + /// Persist the manifest to `dir`, creating the directory if needed. + pub fn save(&self, dir: &Path) -> Result<(), String> { + std::fs::create_dir_all(dir) + .map_err(|err| format!("Cannot create plugins directory {:?}: {}", dir, err))?; + let path = Self::path(dir); + let contents = toml::to_string(self) + .map_err(|err| format!("Cannot serialize plugin manifest: {}", err))?; + std::fs::write(&path, contents) + .map_err(|err| format!("Cannot write plugin manifest at {:?}: {}", path, err)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TempDir(PathBuf); + + impl TempDir { + fn new(tag: &str) -> Self { + let dir = std::env::temp_dir().join(format!( + "scope-installed-test-{}-{}", + std::process::id(), + tag + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + TempDir(dir) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn missing_manifest_is_empty() { + let tmp = TempDir::new("missing"); + let installed = Installed::load(tmp.path()).unwrap(); + assert!(installed.names().is_empty()); + } + + #[test] + fn add_dedupes_and_reports_novelty() { + let mut installed = Installed::default(); + assert!(installed.add("analytics")); + assert!(!installed.add("analytics")); + assert!(installed.add("auto_test")); + assert_eq!(installed.names(), ["analytics", "auto_test"]); + } + + #[test] + fn save_then_load_round_trips() { + let tmp = TempDir::new("roundtrip"); + let mut installed = Installed::default(); + installed.add("analytics"); + installed.add("auto_test"); + installed.save(tmp.path()).unwrap(); + + let reloaded = Installed::load(tmp.path()).unwrap(); + assert_eq!(reloaded.names(), ["analytics", "auto_test"]); + assert!(reloaded.contains("analytics")); + assert!(!reloaded.contains("nope")); + } + + #[test] + fn save_creates_missing_directory() { + let tmp = TempDir::new("mkdir"); + let nested = tmp.path().join("a").join("b"); + let mut installed = Installed::default(); + installed.add("analytics"); + installed.save(&nested).unwrap(); + assert_eq!(Installed::load(&nested).unwrap().names(), ["analytics"]); + } + + #[test] + fn malformed_manifest_is_error() { + let tmp = TempDir::new("malformed"); + std::fs::write(Installed::path(tmp.path()), "plugins = not_a_list\n").unwrap(); + let err = Installed::load(tmp.path()).unwrap_err(); + assert!(err.contains("Cannot parse plugin manifest"), "{err}"); + } +} diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index ac09efb..33d8a46 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -1,5 +1,6 @@ pub mod bridge; pub mod engine; +pub mod installed; pub mod messages; pub mod method_call; pub mod shell; diff --git a/tests/tui_e2e.rs b/tests/tui_e2e.rs index db025ae..4b5c3e1 100644 --- a/tests/tui_e2e.rs +++ b/tests/tui_e2e.rs @@ -68,20 +68,50 @@ struct Tui { _tmp: tempfile::TempDir, } +/// Options for [`Tui::start_with`]. Defaults spawn a plain TUI `scope serial` +/// with no tags, config, or pre-installed plugins. +#[derive(Default)] +struct StartOpts<'a> { + /// Tag file entries (`name`, `value`). + tags: &'a [(&'a str, &'a str)], + /// Contents of `config.toml`, or `None` for no config file. + config_toml: Option<&'a str>, + /// Launch with the `--headless` global flag. + headless: bool, + /// Plugins to pre-install as `(name, lua_source)`: each is written as + /// `/.lua` and listed in a generated `installed.toml`. + installed_plugins: &'a [(&'a str, &'a str)], + /// Verbatim `installed.toml` contents, overriding the generated manifest. + /// Use to seed a malformed manifest; the plugin `.lua` files (if any) still + /// come from `installed_plugins`. + raw_manifest: Option<&'a str>, +} + impl Tui { /// Launch `scope serial` connected to a fresh virtual serial port, with an /// optional tag file built from `tags`. fn start(tags: &[(&str, &str)]) -> Tui { - Self::start_with_config(tags, None, false) + Self::start_with(StartOpts { + tags, + ..Default::default() + }) } - /// Like [`start`](Self::start), but also (optionally) writes `config_toml` - /// where `scope` will read it, and can launch in `--headless` mode. The - /// config directory is always isolated to the temp tree (via - /// `HOME`/`XDG_CONFIG_HOME`), so a real user config can never affect a test; - /// when `config_toml` is `Some`, the file is written to the location - /// `dirs::config_dir()` resolves on each platform. - fn start_with_config(tags: &[(&str, &str)], config_toml: Option<&str>, headless: bool) -> Tui { + /// Launch `scope` under [`StartOpts`]. The config directory is always + /// isolated to the temp tree (via `HOME`/`XDG_CONFIG_HOME`), so a real user + /// config can never affect a test; `config_toml` (when set) is written to the + /// location `dirs::config_dir()` resolves on each platform, and any + /// pre-installed plugins / manifest are seeded into the plugins directory so + /// the engine auto-loads them at start-up (issue #36). + fn start_with(opts: StartOpts) -> Tui { + let StartOpts { + tags, + config_toml, + headless, + installed_plugins, + raw_manifest, + } = opts; + let serial = VirtualSerial::new(); let tmp = tempfile::tempdir().expect("tempdir"); @@ -109,6 +139,32 @@ impl Tui { } } + // Pre-install plugins: write each `.lua` into the plugins dir and + // a manifest (either the verbatim `raw_manifest`, or one generated from + // the plugin names), under both candidate config roots, so the engine + // sees them at start-up regardless of platform. + if !installed_plugins.is_empty() || raw_manifest.is_some() { + let manifest = raw_manifest.map(str::to_string).unwrap_or_else(|| { + format!( + "plugins = [{}]\n", + installed_plugins + .iter() + .map(|(name, _)| format!("\"{name}\"")) + .collect::>() + .join(", ") + ) + }); + for base in [xdg.join("scope"), mac_cfg.join("scope")] { + let plugins = base.join("plugins"); + std::fs::create_dir_all(&plugins).expect("create plugins dir"); + for (name, source) in installed_plugins { + std::fs::write(plugins.join(format!("{name}.lua")), source) + .expect("write plugin"); + } + std::fs::write(plugins.join("installed.toml"), &manifest).expect("write manifest"); + } + } + let pair = native_pty_system() .openpty(PtySize { rows: ROWS, @@ -160,6 +216,17 @@ impl Tui { } } + /// The staged-plugins directory the running app uses (`/scope/ + /// plugins`), resolved to the platform-correct root under the isolated temp + /// config tree. Used to seed or inspect the install manifest. + fn plugins_dir(&self) -> PathBuf { + #[cfg(target_os = "macos")] + let base = self._tmp.path().join("Library").join("Application Support"); + #[cfg(not(target_os = "macos"))] + let base = self._tmp.path().join("xdg"); + base.join("scope").join("plugins") + } + /// The currently rendered screen as plain text (like `tmux capture-pane -p`). fn screen(&self) -> String { self.parser.lock().unwrap().screen().contents() @@ -515,7 +582,10 @@ fn custom_shortcut_from_config_remaps_action() { // disables the built-in one. Move `record` from Ctrl+R to Ctrl+G. (Ctrl+G is // 0x07; we avoid Ctrl+J=0x0a / Ctrl+I=0x09, which terminals send as // Enter/Tab.) - let mut tui = Tui::start_with_config(&[], Some("[shortcuts]\nrecord = \"Ctrl+G\"\n"), false); + let mut tui = Tui::start_with(StartOpts { + config_toml: Some("[shortcuts]\nrecord = \"Ctrl+G\"\n"), + ..Default::default() + }); tui.wait_until_ready(); // The old key is now unbound: Ctrl+R (0x12) must NOT start a recording. Use @@ -543,7 +613,10 @@ fn headless_ctrl_f_is_swallowed_in_command_bar() { // Ctrl+F, then type a sentinel 'Z'. The blinking prompt mirrors the command // line as `> `, so a correct swallow shows `> Z`; a leaked 'f' would // show `> fZ` (and `> Z` would never appear). - let mut tui = Tui::start_with_config(&[], None, true); + let mut tui = Tui::start_with(StartOpts { + headless: true, + ..Default::default() + }); tui.type_text("\x0b"); // Ctrl+K -> command bar tui.type_text("\x06"); // Ctrl+F -> must be swallowed @@ -555,3 +628,131 @@ fn headless_ctrl_f_is_swallowed_in_command_bar() { "Ctrl+F must not be typed into the headless command bar.\n{screen}" ); } + +#[test] +fn plugin_install_persists_to_manifest() { + // Issue #36: `!plugin install ` loads the plugin and records it in the + // manifest so future sessions auto-load it. Install a trivial plugin from an + // absolute path (independent of the app's cwd), then assert the success log + // and that `installed.toml` now lists it. + let plugin_home = tempfile::tempdir().expect("plugin tempdir"); + let plugin_path = plugin_home.path().join("e2e_installed.lua"); + std::fs::write(&plugin_path, "local M = {}\nreturn M\n").expect("write plugin"); + + let mut tui = Tui::start(&[]); + tui.wait_until_ready(); + + tui.type_text(&format!( + "!plugin install {}", + plugin_path.to_str().unwrap() + )); + tui.press_enter(); + + tui.wait_for("installed", SETTLE); + + let manifest = std::fs::read_to_string(tui.plugins_dir().join("installed.toml")) + .expect("manifest written after install"); + assert!( + manifest.contains("e2e_installed"), + "manifest must list the installed plugin.\n{manifest}" + ); +} + +#[test] +fn installed_plugin_autoloads_at_startup() { + // A plugin recorded in the manifest is auto-loaded at start-up (the whole + // point of install): no `!plugin load` is typed, yet the load log appears. + let tui = Tui::start_with(StartOpts { + installed_plugins: &[("autoloaded_plugin", "local M = {}\nreturn M\n")], + ..Default::default() + }); + + tui.wait_for("autoloaded_plugin", SETTLE); +} + +#[test] +fn plugin_install_failure_does_not_persist() { + // Safety invariant: a plugin that fails to load must NOT be recorded in the + // manifest (else every future session would try to auto-load a broken + // plugin). Install a path that doesn't exist and assert the error is logged + // and the manifest never gains the name. + let plugin_home = tempfile::tempdir().expect("plugin tempdir"); + let ghost = plugin_home.path().join("ghost_plugin.lua"); // deliberately not created + + let mut tui = Tui::start(&[]); + tui.wait_until_ready(); + + tui.type_text(&format!("!plugin install {}", ghost.to_str().unwrap())); + tui.press_enter(); + + tui.wait_for("doesn't exist", SETTLE); + + let recorded = + std::fs::read_to_string(tui.plugins_dir().join("installed.toml")).unwrap_or_default(); + assert!( + !recorded.contains("ghost_plugin"), + "a failed install must not persist to the manifest.\n{recorded}" + ); +} + +#[test] +fn malformed_manifest_at_startup_is_non_fatal() { + // A corrupt manifest must be logged and skipped, never abort start-up (it is + // program-managed state, not the user's config.toml). The app must still + // become interactive; the parse error being logged proves the engine handled + // it gracefully (rather than the process aborting or the engine dying mute). + let tui = Tui::start_with(StartOpts { + raw_manifest: Some("plugins = not_a_list\n"), + ..Default::default() + }); + + tui.wait_until_ready(); + tui.wait_for("Cannot parse plugin manifest", SETTLE); +} + +#[test] +fn plugin_list_shows_installed_set() { + // `!plugin list` reports the empty set and, after an install, the names. + let plugin_home = tempfile::tempdir().expect("plugin tempdir"); + let plugin_path = plugin_home.path().join("listed_plugin.lua"); + std::fs::write(&plugin_path, "local M = {}\nreturn M\n").expect("write plugin"); + + let mut tui = Tui::start(&[]); + tui.wait_until_ready(); + + tui.type_text("!plugin list"); + tui.press_enter(); + tui.wait_for("No plugins installed", SETTLE); + + tui.type_text(&format!( + "!plugin install {}", + plugin_path.to_str().unwrap() + )); + tui.press_enter(); + tui.wait_for("installed", SETTLE); + + tui.type_text("!plugin list"); + tui.press_enter(); + // The "Installed plugins:" prefix only comes from the list command's + // non-empty branch (the name alone is already on screen from the install). + tui.wait_for("Installed plugins:", SETTLE); +} + +#[test] +fn plugin_install_rejects_reserved_name() { + // `scope`/`shell` are the bundled stdlib names; installing one must error and + // not be persisted. + let mut tui = Tui::start(&[]); + tui.wait_until_ready(); + + tui.type_text("!plugin install scope"); + tui.press_enter(); + tui.wait_for("reserved plugin name", SETTLE); + + let recorded = + std::fs::read_to_string(tui.plugins_dir().join("installed.toml")).unwrap_or_default(); + assert!( + !recorded.contains("scope"), + "a reserved name must not be installed.\n{recorded}" + ); +}