From 95f5fbe7c2c8bd9c1acf90817d18b0e904be16a3 Mon Sep 17 00:00:00 2001 From: reviewer Date: Sat, 17 Oct 2026 21:10:16 +0800 Subject: [PATCH 1/2] [CTX-1303] fix: keep plugin settings diagnostics host-internal --- .../bitty-runtime/src/plugin_runtime/mod.rs | 31 +- .../src/plugin_runtime/plugin_settings.rs | 286 ++++++++++++++---- .../tests/plugin_settings_file.rs | 104 ++++++- crates/bitty-terminal/src/plugin_runtime.rs | 3 + 4 files changed, 348 insertions(+), 76 deletions(-) diff --git a/crates/bitty-runtime/src/plugin_runtime/mod.rs b/crates/bitty-runtime/src/plugin_runtime/mod.rs index fa1b3038..56ff44b0 100644 --- a/crates/bitty-runtime/src/plugin_runtime/mod.rs +++ b/crates/bitty-runtime/src/plugin_runtime/mod.rs @@ -550,6 +550,7 @@ struct PluginEntry { vm: Option>>, services: Option>, registrations: RegistrationCapture, + settings_diagnostics: Vec, } type DiscoveryResults = Vec<(PluginId, Result<(), PluginRuntimeError>)>; @@ -1788,6 +1789,7 @@ impl PluginRuntime { vm: None, services: None, registrations: RegistrationCapture::new(), + settings_diagnostics: Vec::new(), }, ); self.order.push(id); @@ -1806,11 +1808,25 @@ impl PluginRuntime { result } + /// Return bounded settings diagnostics for the currently stored plugin + /// generation. Diagnostics stay on the host-owned entry rather than on + /// [`ActivationReport`], preserving source compatibility for downstream + /// exhaustive struct literals and destructuring. + #[must_use] + pub fn settings_diagnostics( + &self, + id: &PluginId, + ) -> Option<&[plugin_settings::PluginSettingsDiagnostic]> { + self.entries + .get(id) + .map(|entry| entry.settings_diagnostics.as_slice()) + } + fn activate_inner(&mut self, id: &PluginId) -> Result { let (manifest, module_root, init_path, recorded_grant, source_class) = { let entry = self .entries - .get(id) + .get_mut(id) .ok_or_else(|| PluginRuntimeError::Lifecycle { plugin: id.to_string(), detail: "plugin not discovered".to_string(), @@ -1824,6 +1840,7 @@ impl PluginRuntime { detail: format!("cannot activate from state {:?}", entry.state), }); } + entry.settings_diagnostics.clear(); // `--safe` is decided by discovery provenance, never by the // self-declared id: a third-party package cannot claim bundled // trust by naming itself `bitty.*` (RFC A.4 rule 6). This check @@ -1990,6 +2007,7 @@ impl PluginRuntime { return Err(error); } } + let settings_diagnostics; let plugin_services = { // CTX-1195 (issue #2010): per-generation settings — manifest // shipped defaults, then the old home (the shared host source), @@ -1998,13 +2016,19 @@ impl PluginRuntime { // fails closed structurally and at the gate. A malformed file // resolves to lower layers (never aborts activation); grants are // untouched by construction (this path names no capability). - let loaded = plugin_settings::load_plugin_settings_with_plugins( - self.settings_dir.as_deref(), + let loaded = plugin_settings::load_plugin_settings_with_plugins_and_diagnostics( + if self.safe_mode { + None + } else { + self.settings_dir.as_deref() + }, id.as_str(), &manifest.settings, Some(&*self.settings), self.settings_alias_registry.as_deref(), ); + let (loaded, diagnostics) = loaded; + settings_diagnostics = diagnostics; let services = Rc::new(PluginServices::new( id.as_str(), store, @@ -2284,6 +2308,7 @@ impl PluginRuntime { let entry = self.entries.get_mut(id).expect("entry exists"); entry.vm = Some(vm.clone()); entry.registrations = capture; + entry.settings_diagnostics = settings_diagnostics; entry.state = LifecycleState::Active; ( entry.package.source_class, diff --git a/crates/bitty-runtime/src/plugin_runtime/plugin_settings.rs b/crates/bitty-runtime/src/plugin_runtime/plugin_settings.rs index 7e58fbd6..f094a7ec 100644 --- a/crates/bitty-runtime/src/plugin_runtime/plugin_settings.rs +++ b/crates/bitty-runtime/src/plugin_runtime/plugin_settings.rs @@ -56,6 +56,9 @@ use bitty_plugin_host::manifest::{SettingDecl, SettingDefault, SettingKind}; use super::LuaValue; use super::services::SettingsSource; +/// Maximum number of settings diagnostics retained for one activation. +pub const MAX_PLUGIN_SETTINGS_DIAGNOSTICS: usize = 4; + // --------------------------------------------------------------------------- // Type agreement (strict: no coercion, ever) // --------------------------------------------------------------------------- @@ -265,6 +268,86 @@ pub struct LoadedPluginSettings { pub file_error: Option, } +/// Non-sensitive settings diagnostic surfaced by the activation report. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PluginSettingsDiagnostic { + /// A short alias has more than one plugin claimant and was not applied. + AmbiguousAlias { alias: String, claimants: usize }, + /// A canonical Lua file was preferred over a present short-alias file. + CanonicalShadowsAlias { alias: String }, + /// Some file entries were ignored because they were undeclared or had an + /// incompatible type. The entry names are intentionally not surfaced. + DroppedEntries { count: usize }, + /// A settings file was rejected and lower-precedence settings were used. + FileRejected, +} + +impl std::fmt::Display for PluginSettingsDiagnostic { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::AmbiguousAlias { alias, claimants } => write!( + formatter, + "short settings alias '{alias}' is ambiguous across {claimants} plugins; alias ignored" + ), + Self::CanonicalShadowsAlias { alias } => write!( + formatter, + "canonical Lua settings take precedence over short alias '{alias}'" + ), + Self::DroppedEntries { count } => { + write!(formatter, "{count} plugin settings entries ignored") + } + Self::FileRejected => { + formatter.write_str("plugin settings file rejected; lower-precedence settings used") + } + } + } +} + +fn push_diagnostic( + diagnostics: &mut Vec, + diagnostic: PluginSettingsDiagnostic, +) { + if diagnostics.len() < MAX_PLUGIN_SETTINGS_DIAGNOSTICS { + diagnostics.push(diagnostic); + } +} + +fn diagnostics_for_loaded( + dropped: &[String], + file_error: Option<&str>, + mut diagnostics: Vec, +) -> Vec { + if !dropped.is_empty() { + push_diagnostic( + &mut diagnostics, + PluginSettingsDiagnostic::DroppedEntries { + count: dropped.len(), + }, + ); + } + if file_error.is_some() { + push_diagnostic(&mut diagnostics, PluginSettingsDiagnostic::FileRejected); + } + diagnostics +} + +fn loaded_settings( + resolved: ResolvedPluginSettings, + file_error: Option, + diagnostics: Vec, +) -> (LoadedPluginSettings, Vec) { + let dropped = resolved.dropped; + let diagnostics = diagnostics_for_loaded(&dropped, file_error.as_deref(), diagnostics); + ( + LoadedPluginSettings { + source: PluginSettingsSource::new(resolved.values), + dropped, + file_error, + }, + diagnostics, + ) +} + fn plain_value_to_lua(value: &PlainConfigValue) -> Option { match value { PlainConfigValue::Bool(value) => Some(LuaValue::Bool(*value)), @@ -355,12 +438,17 @@ fn lua_config_paths( config_dir: &Path, plugin_id: &str, discovered_ids: Option<&[String]>, -) -> Option<(std::path::PathBuf, Option)> { +) -> Option<( + std::path::PathBuf, + Option, + Vec, +)> { if !plugin_file::valid_plugin_file_id(plugin_id) { return None; } let plugins = config_dir.join(plugin_file::PLUGINS_DIR_NAME); let canonical = plugins.join(format!("{plugin_id}.lua")); + let mut diagnostics = Vec::new(); let alias = discovered_ids.and_then(|ids| { if ids.iter().any(|id| !plugin_file::valid_plugin_file_id(id)) { return None; @@ -376,9 +464,22 @@ fn lua_config_paths( (name == short_name && plugin_file::valid_plugin_file_id(id)).then_some(owner) }) .collect(); - (owners.len() == 1).then(|| plugins.join(format!("{short_name}.lua"))) + if owners.len() == 1 { + Some(plugins.join(format!("{short_name}.lua"))) + } else { + if owners.len() > 1 { + push_diagnostic( + &mut diagnostics, + PluginSettingsDiagnostic::AmbiguousAlias { + alias: short_name.to_string(), + claimants: owners.len(), + }, + ); + } + None + } }); - Some((canonical, alias)) + Some((canonical, alias, diagnostics)) } #[derive(Debug, PartialEq, Eq)] @@ -585,6 +686,26 @@ pub fn load_plugin_settings_with_plugins( base: Option<&dyn SettingsSource>, discovered_ids: Option<&[String]>, ) -> LoadedPluginSettings { + load_plugin_settings_with_plugins_and_diagnostics( + config_dir, + plugin_id, + declared, + base, + discovered_ids, + ) + .0 +} + +/// Load settings and retain host diagnostics for the runtime observability +/// path. This is intentionally crate-private: the public loader keeps the +/// established [`LoadedPluginSettings`] layout source-compatible. +pub(crate) fn load_plugin_settings_with_plugins_and_diagnostics( + config_dir: Option<&Path>, + plugin_id: &str, + declared: &[SettingDecl], + base: Option<&dyn SettingsSource>, + discovered_ids: Option<&[String]>, +) -> (LoadedPluginSettings, Vec) { let path = match (config_dir, plugin_file::valid_plugin_file_id(plugin_id)) { // Validated ids match `owner.name` with no separators, so joining // the file name cannot escape the `plugins/` directory (fail-closed @@ -592,33 +713,49 @@ pub fn load_plugin_settings_with_plugins( (Some(dir), true) => lua_config_paths(dir, plugin_id, discovered_ids), _ => None, }; + let mut path_diagnostics = Vec::new(); + let mut canonical_shadow_alias = None; let lua_bytes = match path { - Some((canonical_path, alias_path)) => match read_bounded_regular_file(&canonical_path) { - Ok(bytes) => Some(bytes), - Err(ConfigReadError::NotFound) => match alias_path { - Some(alias_path) => match read_bounded_regular_file(&alias_path) { - Ok(bytes) => Some(bytes), - Err(ConfigReadError::NotFound) => None, - Err(ConfigReadError::Rejected(error)) => { - let resolved = merge_lua_settings(declared, None, base); - return LoadedPluginSettings { - source: PluginSettingsSource::new(resolved.values), - dropped: resolved.dropped, - file_error: Some(error), - }; + Some((canonical_path, alias_path, diagnostics)) => { + path_diagnostics = diagnostics; + match read_bounded_regular_file(&canonical_path) { + Ok(bytes) => { + if let Some(alias_path) = alias_path.as_deref() + && std::fs::symlink_metadata(alias_path).is_ok() + { + let alias = alias_path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or_default(); + if !alias.is_empty() { + canonical_shadow_alias = Some(alias.to_string()); + } } + Some(bytes) + } + Err(ConfigReadError::NotFound) => match alias_path { + Some(alias_path) => match read_bounded_regular_file(&alias_path) { + Ok(bytes) => Some(bytes), + Err(ConfigReadError::NotFound) => None, + Err(ConfigReadError::Rejected(error)) => { + return loaded_settings( + merge_lua_settings(declared, None, base), + Some(error), + path_diagnostics, + ); + } + }, + None => None, }, - None => None, - }, - Err(ConfigReadError::Rejected(error)) => { - let resolved = merge_lua_settings(declared, None, base); - return LoadedPluginSettings { - source: PluginSettingsSource::new(resolved.values), - dropped: resolved.dropped, - file_error: Some(error), - }; + Err(ConfigReadError::Rejected(error)) => { + return loaded_settings( + merge_lua_settings(declared, None, base), + Some(error), + path_diagnostics, + ); + } } - }, + } None => None, }; if let Some(bytes) = lua_bytes { @@ -635,23 +772,31 @@ pub fn load_plugin_settings_with_plugins( }); match lua_result.and_then(plain_settings_to_lua) { Ok((values, mut dropped)) => { + if let Some(alias) = canonical_shadow_alias { + push_diagnostic( + &mut path_diagnostics, + PluginSettingsDiagnostic::CanonicalShadowsAlias { alias }, + ); + } let resolved = merge_lua_settings(declared, Some(&values), base); dropped.extend(resolved.dropped); dropped.sort(); dropped.dedup(); - return LoadedPluginSettings { - source: PluginSettingsSource::new(resolved.values), - dropped, - file_error: None, - }; + return loaded_settings( + ResolvedPluginSettings { + values: resolved.values, + dropped, + }, + None, + path_diagnostics, + ); } Err(error) => { - let resolved = merge_lua_settings(declared, None, base); - return LoadedPluginSettings { - source: PluginSettingsSource::new(resolved.values), - dropped: resolved.dropped, - file_error: Some(format!("plugin Lua config rejected: {error}")), - }; + return loaded_settings( + merge_lua_settings(declared, None, base), + Some(format!("plugin Lua config rejected: {error}")), + path_diagnostics, + ); } } } @@ -665,47 +810,27 @@ pub fn load_plugin_settings_with_plugins( }; let Some(path) = toml_path else { let resolved = resolve_plugin_settings(declared, None, base); - return LoadedPluginSettings { - source: PluginSettingsSource::new(resolved.values), - dropped: resolved.dropped, - file_error: None, - }; + return loaded_settings(resolved, None, path_diagnostics); }; let bytes = match read_bounded_regular_file(&path) { Ok(bytes) => bytes, Err(ConfigReadError::NotFound) => { let resolved = resolve_plugin_settings(declared, None, base); - return LoadedPluginSettings { - source: PluginSettingsSource::new(resolved.values), - dropped: resolved.dropped, - file_error: None, - }; + return loaded_settings(resolved, None, path_diagnostics); } Err(ConfigReadError::Rejected(error)) => { let resolved = resolve_plugin_settings(declared, None, base); - return LoadedPluginSettings { - source: PluginSettingsSource::new(resolved.values), - dropped: resolved.dropped, - file_error: Some(error), - }; + return loaded_settings(resolved, Some(error), path_diagnostics); } }; match plugin_file::parse_plugin_file(&bytes) { Ok(parsed) => { let resolved = resolve_plugin_settings(declared, Some(&parsed), base); - LoadedPluginSettings { - source: PluginSettingsSource::new(resolved.values), - dropped: resolved.dropped, - file_error: None, - } + loaded_settings(resolved, None, path_diagnostics) } Err(error) => { let resolved = resolve_plugin_settings(declared, None, base); - LoadedPluginSettings { - source: PluginSettingsSource::new(resolved.values), - dropped: resolved.dropped, - file_error: Some(error.to_string()), - } + loaded_settings(resolved, Some(error.to_string()), path_diagnostics) } } } @@ -994,7 +1119,7 @@ mod tests { ) .expect("canonical"); let ids = vec!["xuepoo.bar".to_string()]; - let loaded = load_plugin_settings_with_plugins( + let (loaded, diagnostics) = load_plugin_settings_with_plugins_and_diagnostics( Some(&dir), "xuepoo.bar", &bar_decls(), @@ -1007,6 +1132,12 @@ mod tests { Some(LuaValue::String("top".to_string())) ); assert!(loaded.file_error.is_none()); + assert_eq!( + diagnostics, + vec![PluginSettingsDiagnostic::CanonicalShadowsAlias { + alias: "bar".to_string() + }] + ); let _ = std::fs::remove_dir_all(&dir); } @@ -1030,7 +1161,7 @@ mod tests { ); assert_eq!(loaded.source.get("show"), Some(LuaValue::Bool(false))); let collision = vec!["xuepoo.bar".to_string(), "other.bar".to_string()]; - let loaded = load_plugin_settings_with_plugins( + let (loaded, diagnostics) = load_plugin_settings_with_plugins_and_diagnostics( Some(&dir), "xuepoo.bar", &bar_decls(), @@ -1039,6 +1170,13 @@ mod tests { ); assert_eq!(loaded.source.get("show"), Some(LuaValue::Bool(true))); assert!(loaded.file_error.is_none()); + assert_eq!( + diagnostics, + vec![PluginSettingsDiagnostic::AmbiguousAlias { + alias: "bar".to_string(), + claimants: 2, + }] + ); let _ = std::fs::remove_dir_all(&dir); } @@ -1053,7 +1191,13 @@ mod tests { std::fs::create_dir_all(&plugins).expect("temp dir"); std::fs::write(plugins.join("xuepoo.bar.lua"), b"return io\n").expect("lua"); std::fs::write(plugins.join("xuepoo.bar.toml"), b"show = false\n").expect("toml"); - let loaded = load_plugin_settings(Some(&dir), "xuepoo.bar", &bar_decls(), None); + let (loaded, _diagnostics) = load_plugin_settings_with_plugins_and_diagnostics( + Some(&dir), + "xuepoo.bar", + &bar_decls(), + None, + None, + ); assert_eq!(loaded.source.get("show"), Some(LuaValue::Bool(true))); assert!(loaded.file_error.is_some()); let _ = std::fs::remove_dir_all(&dir); @@ -1073,7 +1217,13 @@ mod tests { b"return { show = 'bad', edge = 'top', name_max_chars = 3.5, hidden = true }\n", ) .expect("lua"); - let loaded = load_plugin_settings(Some(&dir), "xuepoo.bar", &bar_decls(), None); + let (loaded, diagnostics) = load_plugin_settings_with_plugins_and_diagnostics( + Some(&dir), + "xuepoo.bar", + &bar_decls(), + None, + None, + ); assert_eq!(loaded.source.get("show"), Some(LuaValue::Bool(true))); assert_eq!( loaded.source.get("edge"), @@ -1084,6 +1234,10 @@ mod tests { Some(LuaValue::Integer(32)) ); assert!(loaded.dropped.iter().any(|key| key == "hidden")); + assert_eq!( + diagnostics, + vec![PluginSettingsDiagnostic::DroppedEntries { count: 3 }] + ); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/bitty-runtime/tests/plugin_settings_file.rs b/crates/bitty-runtime/tests/plugin_settings_file.rs index 18f9031a..45ed1a0a 100644 --- a/crates/bitty-runtime/tests/plugin_settings_file.rs +++ b/crates/bitty-runtime/tests/plugin_settings_file.rs @@ -25,9 +25,10 @@ use std::rc::Rc; use bitty_plugin_host::manifest::PluginId; use bitty_runtime::plugin_runtime::package::{LocalInstallOptions, install_local_dir, set_enabled}; +use bitty_runtime::plugin_runtime::plugin_settings::PluginSettingsDiagnostic; use bitty_runtime::plugin_runtime::{ - EmptySettings, LifecycleState, LuaValue, PluginRuntime, PluginRuntimeConfig, SnapshotSource, - UnavailableSnapshot, + ActivationReport, EmptySettings, LifecycleState, LuaValue, PluginRuntime, PluginRuntimeConfig, + SnapshotSource, UnavailableSnapshot, }; const BAR_ID: &str = "bitty-terminal.bar"; @@ -139,6 +140,14 @@ fn write_user_file(config_dir: &Path, body: &[u8]) { } fn activate_bar(root: PathBuf, config_dir: Option) -> PluginRuntime { + let (runtime, _) = activate_bar_with_report(root, config_dir); + runtime +} + +fn activate_bar_with_report( + root: PathBuf, + config_dir: Option, +) -> (PluginRuntime, ActivationReport) { let data = temp_dir("data"); let mut runtime = PluginRuntime::new(PluginRuntimeConfig { safe_mode: false, @@ -154,7 +163,7 @@ fn activate_bar(root: PathBuf, config_dir: Option) -> PluginRuntime { let id = PluginId::new(BAR_ID).expect("id"); let report = runtime.activate(&id).expect("bar fixture must activate"); assert_eq!(report.state, LifecycleState::Active); - runtime + (runtime, report) } fn read_settings(runtime: &mut PluginRuntime) -> String { @@ -195,6 +204,51 @@ fn user_file_overrides_per_key_end_to_end() { ); } +#[test] +fn canonical_lua_shadow_diagnostic_surfaces_on_activation() { + let root = temp_dir("root-canonical-shadow"); + write_bar_plugin(&root); + let config = temp_dir("config-canonical-shadow"); + let plugins = config.join("plugins"); + std::fs::create_dir_all(&plugins).expect("plugins dir"); + std::fs::write( + plugins.join("bar.lua"), + b"return { show = false, edge = 'top' }\n", + ) + .expect("alias"); + std::fs::write( + plugins.join(format!("{BAR_ID}.lua")), + b"return { show = true, edge = 'top' }\n", + ) + .expect("canonical"); + + let mut runtime = PluginRuntime::new(PluginRuntimeConfig { + safe_mode: false, + data_dir: Some(temp_dir("data-canonical-shadow")), + store_root: None, + bundled_roots: Vec::new(), + third_party_roots: vec![root], + settings: Rc::new(EmptySettings), + snapshot: Rc::new(UnavailableSnapshot) as Rc, + }); + runtime.set_settings_dir(Some(config)); + runtime.discover(); + let id = PluginId::new(BAR_ID).expect("id"); + runtime.activate(&id).expect("bar fixture must activate"); + assert_eq!( + runtime + .settings_diagnostics(&id) + .expect("diagnostics entry"), + vec![PluginSettingsDiagnostic::CanonicalShadowsAlias { + alias: "bar".to_string(), + }] + ); + assert_eq!( + read_settings(&mut runtime), + "show=true;edge=top;show_single=false;name_max_chars=32;modules=workspaces" + ); +} + #[test] fn hostile_file_fails_to_defaults_without_abort_end_to_end() { let root = temp_dir("root-hostile"); @@ -207,7 +261,14 @@ fn hostile_file_fails_to_defaults_without_abort_end_to_end() { &config, b"show = \"yes\"\nedge = 3.5\nshow_single = true\nname_max_chars = \"32\"\nmodules = \"clock\"\nfrobnicate = 7\n", ); - let mut runtime = activate_bar(root, Some(config)); + let (mut runtime, _) = activate_bar_with_report(root, Some(config)); + let id = PluginId::new(BAR_ID).expect("id"); + assert_eq!( + runtime + .settings_diagnostics(&id) + .expect("diagnostics entry"), + vec![PluginSettingsDiagnostic::DroppedEntries { count: 5 }] + ); assert_eq!( read_settings(&mut runtime), "show=true;edge=bottom;show_single=true;name_max_chars=32;modules=workspaces" @@ -223,7 +284,14 @@ fn malformed_file_keeps_plugin_alive_on_defaults_end_to_end() { // plugin still activates and serves defaults. A `[capabilities]`-looking // section grants nothing — it is not even parsed as config. write_user_file(&config, b"[capabilities]\nui.rich = true\n"); - let mut runtime = activate_bar(root, Some(config)); + let (mut runtime, _) = activate_bar_with_report(root, Some(config)); + let id = PluginId::new(BAR_ID).expect("id"); + assert_eq!( + runtime + .settings_diagnostics(&id) + .expect("diagnostics entry"), + vec![PluginSettingsDiagnostic::FileRejected] + ); assert_eq!( read_settings(&mut runtime), "show=true;edge=bottom;show_single=false;name_max_chars=32;modules=workspaces" @@ -270,7 +338,11 @@ fn short_alias_fails_closed_in_safe_mode_with_configured_store() { let config = temp_dir("config-safe-mode-configured-store"); let plugins = config.join("plugins"); std::fs::create_dir_all(&plugins).expect("plugins dir"); - std::fs::write(plugins.join("bar.lua"), b"return { show = false }\n").expect("alias"); + std::fs::write( + plugins.join(format!("{BAR_ID}.toml")), + b"show = false\nedge = \"top\"\n", + ) + .expect("external settings"); let mut runtime = PluginRuntime::new(PluginRuntimeConfig { safe_mode: true, @@ -284,7 +356,16 @@ fn short_alias_fails_closed_in_safe_mode_with_configured_store() { runtime.set_settings_dir(Some(config)); runtime.discover(); let id = PluginId::new(BAR_ID).expect("id"); - runtime.activate(&id).expect("bar fixture must activate"); + runtime + .activate(&id) + .expect("bundled bar fixture must activate"); + assert!( + runtime + .settings_diagnostics(&id) + .expect("diagnostics entry") + .is_empty(), + "safe mode must not emit diagnostics for external settings it did not read" + ); assert_eq!( read_settings(&mut runtime), "show=true;edge=bottom;show_single=false;name_max_chars=32;modules=workspaces" @@ -333,6 +414,15 @@ fn short_alias_fails_closed_for_disabled_installed_collision() { assert!(results.iter().all(|(_, result)| result.is_ok())); let id = PluginId::new(BAR_ID).expect("id"); runtime.activate(&id).expect("bar fixture must activate"); + assert_eq!( + runtime + .settings_diagnostics(&id) + .expect("diagnostics entry"), + vec![PluginSettingsDiagnostic::AmbiguousAlias { + alias: "bar".to_string(), + claimants: 2, + }] + ); assert_eq!( read_settings(&mut runtime), "show=true;edge=bottom;show_single=false;name_max_chars=32;modules=workspaces" diff --git a/crates/bitty-terminal/src/plugin_runtime.rs b/crates/bitty-terminal/src/plugin_runtime.rs index 655284de..549d0c6f 100644 --- a/crates/bitty-terminal/src/plugin_runtime.rs +++ b/crates/bitty-terminal/src/plugin_runtime.rs @@ -490,6 +490,9 @@ pub(crate) fn discover_with_roots( }); } Ok(report) => { + for diagnostic in runtime.settings_diagnostics(&id).unwrap_or_default() { + crate::logging::warn(|| format!("bitty: plugin '{id}' settings: {diagnostic}")); + } let verification = if report.unverified { "unverified" } else { From fc1d5a0c7ed050944585b786136a18fc450af29d Mon Sep 17 00:00:00 2001 From: reviewer Date: Sat, 17 Oct 2026 21:49:27 +0800 Subject: [PATCH 2/2] [CTX-1303] fix(runtime): avoid let-chain in plugin settings --- .../src/plugin_runtime/plugin_settings.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/bitty-runtime/src/plugin_runtime/plugin_settings.rs b/crates/bitty-runtime/src/plugin_runtime/plugin_settings.rs index f094a7ec..9f78e2e4 100644 --- a/crates/bitty-runtime/src/plugin_runtime/plugin_settings.rs +++ b/crates/bitty-runtime/src/plugin_runtime/plugin_settings.rs @@ -720,15 +720,15 @@ pub(crate) fn load_plugin_settings_with_plugins_and_diagnostics( path_diagnostics = diagnostics; match read_bounded_regular_file(&canonical_path) { Ok(bytes) => { - if let Some(alias_path) = alias_path.as_deref() - && std::fs::symlink_metadata(alias_path).is_ok() - { - let alias = alias_path - .file_stem() - .and_then(|stem| stem.to_str()) - .unwrap_or_default(); - if !alias.is_empty() { - canonical_shadow_alias = Some(alias.to_string()); + if let Some(alias_path) = alias_path.as_deref() { + if std::fs::symlink_metadata(alias_path).is_ok() { + let alias = alias_path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or_default(); + if !alias.is_empty() { + canonical_shadow_alias = Some(alias.to_string()); + } } } Some(bytes)