systemprompt_cli/commands/core/services/
versions.rs1use std::collections::{BTreeMap, BTreeSet};
13use std::path::Path;
14
15use anyhow::{Context, Result};
16use serde::Deserialize;
17use systemprompt_manifest::services::bundle::ServicesBundleManifest;
18
19pub const PLUGIN_CONFIG_FILE: &str = "config.yaml";
20
21#[derive(Debug, Deserialize)]
22struct PluginVersionFile {
23 plugin: PluginVersion,
24}
25
26#[derive(Debug, Deserialize)]
27struct PluginVersion {
28 version: String,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct VersionDrift {
33 pub plugin: String,
34 pub version: String,
35 pub changed_files: usize,
36}
37
38pub fn detect_drift(
39 previous: &ServicesBundleManifest,
40 previous_root: &Path,
41 current: &ServicesBundleManifest,
42 current_root: &Path,
43) -> Result<Vec<VersionDrift>> {
44 let previous_files = plugin_files(previous);
45 let current_files = plugin_files(current);
46
47 let mut drift = Vec::new();
48 for (plugin, before) in &previous_files {
49 let Some(after) = current_files.get(plugin) else {
50 continue;
51 };
52 let changed = changed_count(before, after);
53 if changed == 0 {
54 continue;
55 }
56 let old_version = read_version(previous_root, plugin)?;
57 let new_version = read_version(current_root, plugin)?;
58 if old_version == new_version {
59 drift.push(VersionDrift {
60 plugin: plugin.clone(),
61 version: new_version.unwrap_or_else(|| "unknown".to_owned()),
62 changed_files: changed,
63 });
64 }
65 }
66 Ok(drift)
67}
68
69fn changed_count(before: &BTreeMap<String, String>, after: &BTreeMap<String, String>) -> usize {
70 let paths: BTreeSet<&String> = before.keys().chain(after.keys()).collect();
71 paths
72 .into_iter()
73 .filter(|path| before.get(*path) != after.get(*path))
74 .count()
75}
76
77fn plugin_files(manifest: &ServicesBundleManifest) -> BTreeMap<String, BTreeMap<String, String>> {
78 let mut grouped: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
79 for entry in &manifest.files {
80 let Some(rest) = entry.path.strip_prefix("plugins/") else {
81 continue;
82 };
83 let Some((plugin, relative)) = rest.split_once('/') else {
84 continue;
85 };
86 grouped
87 .entry(plugin.to_owned())
88 .or_default()
89 .insert(relative.to_owned(), entry.sha256.clone());
90 }
91 grouped
92}
93
94fn read_version(root: &Path, plugin: &str) -> Result<Option<String>> {
95 let path = root.join("plugins").join(plugin).join(PLUGIN_CONFIG_FILE);
96 if !path.is_file() {
97 return Ok(None);
98 }
99 let raw = std::fs::read_to_string(&path)
100 .with_context(|| format!("Failed to read {}", path.display()))?;
101 let parsed: PluginVersionFile = serde_yaml::from_str(&raw)
102 .with_context(|| format!("Failed to parse {}", path.display()))?;
103 Ok(Some(parsed.plugin.version))
104}