1use crate::skills::manifest::{ManifestCommand, PluginManifest};
4
5#[derive(Debug, Clone, PartialEq, Eq, Default)]
6pub struct PluginUpdateDiff {
7 pub version_change: Option<(Option<String>, Option<String>)>,
8 pub added_permissions: Vec<String>,
9 pub removed_permissions: Vec<String>,
10 pub added_hooks: Vec<String>,
11 pub removed_hooks: Vec<String>,
12 pub extension_command_change: Option<(Option<String>, Option<String>)>,
13 pub added_config_keys: Vec<String>,
14 pub removed_config_keys: Vec<String>,
15 pub added_commands: Vec<String>,
16 pub removed_commands: Vec<String>,
17}
18
19impl PluginUpdateDiff {
20 pub fn is_empty(&self) -> bool {
21 self == &Self::default()
22 }
23
24 pub fn lines(&self) -> Vec<String> {
25 let mut lines = Vec::new();
26 if let Some((old, new)) = &self.version_change {
27 lines.push(format!("version: {} -> {}", old.as_deref().unwrap_or("unspecified"), new.as_deref().unwrap_or("unspecified")));
28 }
29 push_list(&mut lines, "added permissions", &self.added_permissions);
30 push_list(&mut lines, "removed permissions", &self.removed_permissions);
31 push_list(&mut lines, "added hooks", &self.added_hooks);
32 push_list(&mut lines, "removed hooks", &self.removed_hooks);
33 if let Some((old, new)) = &self.extension_command_change {
34 lines.push(format!("extension command: {} -> {}", old.as_deref().unwrap_or("none"), new.as_deref().unwrap_or("none")));
35 }
36 push_list(&mut lines, "added config keys", &self.added_config_keys);
37 push_list(&mut lines, "removed config keys", &self.removed_config_keys);
38 push_list(&mut lines, "added commands", &self.added_commands);
39 push_list(&mut lines, "removed commands", &self.removed_commands);
40 if lines.is_empty() {
41 lines.push("no manifest capability changes detected".to_string());
42 }
43 lines
44 }
45}
46
47fn push_list(lines: &mut Vec<String>, label: &str, values: &[String]) {
48 if !values.is_empty() {
49 lines.push(format!("{}: {}", label, values.join(", ")));
50 }
51}
52
53pub fn diff_plugin_manifests(old: &PluginManifest, new: &PluginManifest) -> PluginUpdateDiff {
54 let mut diff = PluginUpdateDiff::default();
55 if old.version != new.version {
56 diff.version_change = Some((old.version.clone(), new.version.clone()));
57 }
58
59 let old_permissions = old.extension.as_ref().map(|e| e.permissions.clone()).unwrap_or_default();
60 let new_permissions = new.extension.as_ref().map(|e| e.permissions.clone()).unwrap_or_default();
61 diff.added_permissions = added(&old_permissions, &new_permissions);
62 diff.removed_permissions = added(&new_permissions, &old_permissions);
63
64 let old_hooks = old.extension.as_ref().map(hook_names).unwrap_or_default();
65 let new_hooks = new.extension.as_ref().map(hook_names).unwrap_or_default();
66 diff.added_hooks = added(&old_hooks, &new_hooks);
67 diff.removed_hooks = added(&new_hooks, &old_hooks);
68
69 let old_ext_cmd = old.extension.as_ref().map(extension_command);
70 let new_ext_cmd = new.extension.as_ref().map(extension_command);
71 if old_ext_cmd != new_ext_cmd {
72 diff.extension_command_change = Some((old_ext_cmd, new_ext_cmd));
73 }
74
75 let old_config = old.extension.as_ref().map(config_keys).unwrap_or_default();
76 let new_config = new.extension.as_ref().map(config_keys).unwrap_or_default();
77 diff.added_config_keys = added(&old_config, &new_config);
78 diff.removed_config_keys = added(&new_config, &old_config);
79
80 let old_commands = command_names(&old.commands);
81 let new_commands = command_names(&new.commands);
82 diff.added_commands = added(&old_commands, &new_commands);
83 diff.removed_commands = added(&new_commands, &old_commands);
84
85 diff
86}
87
88fn added(old: &[String], new: &[String]) -> Vec<String> {
89 let mut out: Vec<String> = new.iter().filter(|v| !old.contains(v)).cloned().collect();
90 out.sort();
91 out
92}
93
94fn hook_names(ext: &crate::extensions::manifest::ExtensionManifest) -> Vec<String> {
95 let mut names: Vec<String> = ext.hooks.iter().map(|h| h.hook.clone()).collect();
96 names.sort();
97 names.dedup();
98 names
99}
100
101fn config_keys(ext: &crate::extensions::manifest::ExtensionManifest) -> Vec<String> {
102 let mut keys: Vec<String> = ext.config.iter().map(|c| c.key.clone()).collect();
103 keys.sort();
104 keys.dedup();
105 keys
106}
107
108fn extension_command(ext: &crate::extensions::manifest::ExtensionManifest) -> String {
109 if ext.args.is_empty() {
110 ext.command.clone()
111 } else {
112 format!("{} {}", ext.command, ext.args.join(" "))
113 }
114}
115
116fn command_names(commands: &[ManifestCommand]) -> Vec<String> {
117 let mut names: Vec<String> = commands
118 .iter()
119 .map(|command| match command {
120 ManifestCommand::Shell(c) => c.name.clone(),
121 ManifestCommand::ExtensionTool(c) => c.name.clone(),
122 ManifestCommand::SkillPrompt(c) => c.name.clone(),
123 ManifestCommand::Interactive(c) => c.name.clone(),
124 })
125 .collect();
126 names.sort();
127 names.dedup();
128 names
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 fn manifest(json: &str) -> PluginManifest {
136 serde_json::from_str(json).unwrap()
137 }
138
139 #[test]
140 fn reports_permission_hook_command_config_and_version_changes() {
141 let old = manifest(r#"{
142 "name":"policy-test",
143 "version":"0.1.0",
144 "commands":[{"name":"old-cmd","command":"printf"}],
145 "extension":{
146 "protocol_version":1,
147 "runtime":"process",
148 "command":"python3",
149 "args":["old.py"],
150 "permissions":["tools.intercept"],
151 "hooks":[{"hook":"before_tool_call"}],
152 "config":[{"key":"endpoint"}]
153 }
154 }"#);
155 let new = manifest(r#"{
156 "name":"policy-test",
157 "version":"0.2.0",
158 "commands":[{"name":"new-cmd","command":"printf"}],
159 "extension":{
160 "protocol_version":1,
161 "runtime":"process",
162 "command":"python3",
163 "args":["new.py"],
164 "permissions":["tools.intercept","privacy.llm_content"],
165 "hooks":[{"hook":"before_tool_call"},{"hook":"on_message_complete"}],
166 "config":[{"key":"api_key"}]
167 }
168 }"#);
169
170 let diff = diff_plugin_manifests(&old, &new);
171 assert_eq!(diff.version_change, Some((Some("0.1.0".into()), Some("0.2.0".into()))));
172 assert_eq!(diff.added_permissions, vec!["privacy.llm_content"]);
173 assert_eq!(diff.added_hooks, vec!["on_message_complete"]);
174 assert_eq!(diff.extension_command_change, Some((Some("python3 old.py".into()), Some("python3 new.py".into()))));
175 assert_eq!(diff.added_config_keys, vec!["api_key"]);
176 assert_eq!(diff.removed_config_keys, vec!["endpoint"]);
177 assert_eq!(diff.added_commands, vec!["new-cmd"]);
178 assert_eq!(diff.removed_commands, vec!["old-cmd"]);
179 }
180}