Skip to main content

synaps_cli/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!("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}