Skip to main content

systemprompt_cli/commands/core/services/
versions.rs

1//! Content-versus-version drift between a services tree and its predecessor.
2//!
3//! A consumer pins a plugin by version, so a plugin whose files changed while
4//! its `version` stayed put ships different content under a name the consumer
5//! already believes it has. The comparison is per-plugin and uses the previous
6//! bundle's own manifest checksums, so it sees exactly the bytes that were
7//! published rather than whatever is in the working tree.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use 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}