#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
use std::collections::{HashMap, HashSet};
use crate::extensions::{
diagnostic::{DiagnosticSeverity, ExtensionDiagnostic},
manifest::{ExtensionEntry, ExtensionManifestFile},
wire::EventName,
};
const BUILTIN_COMMANDS: &[&str] = &[
"help",
"quit",
"new",
"compact",
"clone",
"model",
"resume",
"fork",
"tree",
"image",
"extensions",
];
const DEFAULT_TIMEOUT_HOT_MS: u64 = 500;
const DEFAULT_TIMEOUT_RARE_MS: u64 = 2000;
#[derive(Debug, Clone, Default)]
pub struct ExtensionRegistry {
pub extensions: Vec<RegisteredExtension>,
pub command_index: HashMap<String, usize>,
pub hook_index: HashMap<EventName, Vec<usize>>,
}
#[derive(Debug, Clone)]
pub struct RegisteredExtension {
pub entry: ExtensionEntry,
pub effective_timeout_ms: u64,
}
impl ExtensionRegistry {
pub fn build(
manifest: ExtensionManifestFile,
diagnostics_out: &mut Vec<ExtensionDiagnostic>,
) -> Self {
let mut registry = Self::default();
let mut seen_names: HashSet<String> = HashSet::new();
for entry in manifest.extensions {
if entry.name.is_empty() {
diagnostics_out.push(ExtensionDiagnostic {
extension_name: "<manifest>".into(),
severity: DiagnosticSeverity::Error,
message: "extension entry with empty `name` skipped".into(),
});
continue;
}
let name = entry.name.clone();
if entry.command.is_empty() {
diagnostics_out.push(ExtensionDiagnostic {
extension_name: name.clone(),
severity: DiagnosticSeverity::Error,
message: format!("extension `{name}` has empty `command`; skipped"),
});
continue;
}
if !seen_names.insert(name.clone()) {
diagnostics_out.push(ExtensionDiagnostic {
extension_name: name.clone(),
severity: DiagnosticSeverity::Error,
message: format!(
"duplicate extension `name = \"{name}\"`; subsequent entry skipped"
),
});
continue;
}
let subscribes_to_hot = entry.hooks.iter().any(|h| h == "before_user_message");
let effective_timeout_ms = entry.timeout_ms.unwrap_or(if subscribes_to_hot {
DEFAULT_TIMEOUT_HOT_MS
} else {
DEFAULT_TIMEOUT_RARE_MS
});
for hook in &entry.hooks {
if !is_known_hook(hook) {
diagnostics_out.push(ExtensionDiagnostic {
extension_name: name.clone(),
severity: DiagnosticSeverity::Warn,
message: format!(
"extension `{name}` subscribes to unknown hook `{hook}`; will never fire"
),
});
}
}
if entry.hooks.is_empty() && entry.commands.is_empty() {
diagnostics_out.push(ExtensionDiagnostic {
extension_name: name.clone(),
severity: DiagnosticSeverity::Warn,
message: format!(
"extension `{name}` registers no hooks or commands; it will never spawn"
),
});
}
let idx = registry.extensions.len();
for cmd in entry.commands.iter().cloned() {
if BUILTIN_COMMANDS.contains(&cmd.as_str()) {
diagnostics_out.push(ExtensionDiagnostic {
extension_name: name.clone(),
severity: DiagnosticSeverity::Error,
message: format!(
"extension `{name}` cannot claim built-in command `/{cmd}`; skipped"
),
});
continue;
}
if registry.command_index.contains_key(&cmd) {
diagnostics_out.push(ExtensionDiagnostic {
extension_name: name.clone(),
severity: DiagnosticSeverity::Error,
message: format!(
"extension `{name}` cannot claim command `/{cmd}`; already owned by an earlier extension"
),
});
continue;
}
registry.command_index.insert(cmd, idx);
}
for hook in &entry.hooks {
if let Some(event) = parse_known_hook(hook) {
registry.hook_index.entry(event).or_default().push(idx);
}
}
registry.extensions.push(RegisteredExtension {
entry,
effective_timeout_ms,
});
}
registry
}
}
fn is_known_hook(name: &str) -> bool {
parse_known_hook(name).is_some()
}
fn parse_known_hook(name: &str) -> Option<EventName> {
match name {
"session_before_switch" => Some(EventName::SessionBeforeSwitch),
"before_user_message" => Some(EventName::BeforeUserMessage),
"command" => Some(EventName::Command),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extensions::{
diagnostic::DiagnosticSeverity, manifest::ExtensionManifestFile, ExtensionEntry,
};
use pretty_assertions::assert_eq;
fn entry(name: &str, command: &str) -> ExtensionEntry {
ExtensionEntry {
name: name.into(),
command: command.into(),
args: Vec::new(),
env: std::collections::HashMap::new(),
timeout_ms: None,
hooks: Vec::new(),
commands: Vec::new(),
}
}
fn manifest_of(entries: Vec<ExtensionEntry>) -> ExtensionManifestFile {
ExtensionManifestFile {
extensions: entries,
}
}
#[test]
fn empty_manifest_produces_empty_registry() {
let mut diags = Vec::new();
let reg = ExtensionRegistry::build(manifest_of(Vec::new()), &mut diags);
assert!(reg.extensions.is_empty());
assert!(reg.command_index.is_empty());
assert!(reg.hook_index.is_empty());
assert!(diags.is_empty());
}
#[test]
fn entry_with_empty_name_is_skipped() {
let mut diags = Vec::new();
let e = entry("", "/bin/x");
let reg = ExtensionRegistry::build(manifest_of(vec![e]), &mut diags);
assert!(reg.extensions.is_empty());
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
assert!(diags[0].message.contains("empty"));
}
#[test]
fn entry_with_empty_command_is_skipped() {
let mut diags = Vec::new();
let e = entry("foo", "");
let reg = ExtensionRegistry::build(manifest_of(vec![e]), &mut diags);
assert!(reg.extensions.is_empty());
assert_eq!(diags.len(), 1);
}
#[test]
fn duplicate_name_skips_subsequent_entries() {
let mut diags = Vec::new();
let first = ExtensionEntry {
hooks: vec!["session_before_switch".into()],
..entry("foo", "/bin/a")
};
let second = ExtensionEntry {
hooks: vec!["session_before_switch".into()],
..entry("foo", "/bin/b")
};
let reg = ExtensionRegistry::build(manifest_of(vec![first, second]), &mut diags);
assert_eq!(reg.extensions.len(), 1);
assert_eq!(reg.extensions[0].entry.command, "/bin/a");
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("duplicate"));
}
#[test]
fn duplicate_commands_skip_subsequent_registrations() {
let mut diags = Vec::new();
let a = ExtensionEntry {
commands: vec!["todo".into()],
..entry("a", "/bin/a")
};
let b = ExtensionEntry {
commands: vec!["todo".into()],
..entry("b", "/bin/b")
};
let reg = ExtensionRegistry::build(manifest_of(vec![a, b]), &mut diags);
assert_eq!(reg.extensions.len(), 2);
assert_eq!(reg.command_index.get("todo"), Some(&0));
assert!(diags.iter().any(|d| d.message.contains("todo")));
}
#[test]
fn commands_colliding_with_builtin_skipped() {
for builtin in [
"help",
"quit",
"new",
"compact",
"clone",
"model",
"resume",
"fork",
"tree",
"image",
"extensions",
] {
let mut diags = Vec::new();
let e = ExtensionEntry {
commands: vec![builtin.into()],
..entry("ext", "/bin/x")
};
let reg = ExtensionRegistry::build(manifest_of(vec![e]), &mut diags);
assert_eq!(reg.extensions.len(), 1);
assert!(
reg.command_index.is_empty(),
"builtin {builtin} must not be claimed"
);
assert!(diags.iter().any(|d| d.message.contains(builtin)));
}
}
#[test]
fn unknown_hook_name_warns_but_extension_still_loads() {
let mut diags = Vec::new();
let e = ExtensionEntry {
hooks: vec!["before_user_message".into(), "future_event_v2".into()],
..entry("ext", "/bin/x")
};
let reg = ExtensionRegistry::build(manifest_of(vec![e]), &mut diags);
assert_eq!(reg.extensions.len(), 1);
assert!(diags.iter().any(|d| {
d.severity == DiagnosticSeverity::Warn && d.message.contains("future_event_v2")
}));
}
#[test]
fn inert_entry_no_hooks_no_commands_warns_but_loads() {
let mut diags = Vec::new();
let e = entry("inert", "/bin/x");
let reg = ExtensionRegistry::build(manifest_of(vec![e]), &mut diags);
assert_eq!(reg.extensions.len(), 1);
assert!(diags
.iter()
.any(|d| d.message.contains("no hooks or commands")));
}
#[test]
fn hook_index_built_correctly() {
let mut diags = Vec::new();
let a = ExtensionEntry {
hooks: vec!["session_before_switch".into()],
..entry("a", "/bin/a")
};
let b = ExtensionEntry {
hooks: vec!["session_before_switch".into(), "before_user_message".into()],
..entry("b", "/bin/b")
};
let reg = ExtensionRegistry::build(manifest_of(vec![a, b]), &mut diags);
assert_eq!(
reg.hook_index
.get(&crate::extensions::EventName::SessionBeforeSwitch),
Some(&vec![0, 1])
);
assert_eq!(
reg.hook_index
.get(&crate::extensions::EventName::BeforeUserMessage),
Some(&vec![1])
);
}
#[test]
fn effective_timeout_defaults_per_hook_class() {
let mut diags = Vec::new();
let hot = ExtensionEntry {
hooks: vec!["before_user_message".into()],
..entry("hot", "/bin/hot")
};
let rare = ExtensionEntry {
hooks: vec!["session_before_switch".into()],
..entry("rare", "/bin/rare")
};
let overridden = ExtensionEntry {
hooks: vec!["before_user_message".into()],
timeout_ms: Some(1500),
..entry("override", "/bin/o")
};
let reg = ExtensionRegistry::build(manifest_of(vec![hot, rare, overridden]), &mut diags);
assert_eq!(reg.extensions[0].effective_timeout_ms, 500);
assert_eq!(reg.extensions[1].effective_timeout_ms, 2000);
assert_eq!(reg.extensions[2].effective_timeout_ms, 1500);
}
}