Skip to main content

agent_engine/skills/
update_diff.rs

1//! Plugin update manifest diff helpers.
2
3use 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}