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