Skip to main content

embeddenator_workspace/
version.rs

1//! Version management and bumping utilities.
2
3use anyhow::{Context, Result};
4use semver::Version;
5use std::collections::HashMap;
6use std::path::Path;
7
8use crate::cargo::CargoManifest;
9use crate::workspace::WorkspaceScanner;
10
11/// Type of version bump to perform.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum BumpType {
14    Major,
15    Minor,
16    Patch,
17    Prerelease,
18}
19
20/// Manages version updates across the workspace.
21pub struct VersionManager {
22    scanner: WorkspaceScanner,
23}
24
25impl VersionManager {
26    /// Create a new version manager for the workspace.
27    pub fn new(workspace_root: impl AsRef<Path>) -> Self {
28        Self {
29            scanner: WorkspaceScanner::new(workspace_root),
30        }
31    }
32
33    /// Bump versions across all embeddenator packages.
34    pub fn bump_versions(&self, bump_type: BumpType, dry_run: bool) -> Result<Vec<VersionChange>> {
35        let mut manifests = self
36            .scanner
37            .find_embeddenator_packages()
38            .context("Failed to find packages")?;
39
40        if manifests.is_empty() {
41            anyhow::bail!("No embeddenator packages found in workspace");
42        }
43
44        let mut changes = Vec::new();
45
46        // Calculate new versions
47        for manifest in &mut manifests {
48            let old_version = manifest.version.clone();
49            let new_version = self.calculate_new_version(&old_version, bump_type)?;
50
51            changes.push(VersionChange {
52                package: manifest.package_name.clone(),
53                path: manifest.path.clone(),
54                old_version: old_version.clone(),
55                new_version: new_version.clone(),
56            });
57
58            if !dry_run {
59                manifest.set_version(&new_version)?;
60            }
61        }
62
63        // Update inter-dependencies
64        if !dry_run {
65            self.update_dependencies(&mut manifests, &changes)?;
66
67            // Save all changes
68            for manifest in manifests {
69                manifest.save()?;
70            }
71        }
72
73        Ok(changes)
74    }
75
76    fn calculate_new_version(&self, current: &Version, bump_type: BumpType) -> Result<Version> {
77        let mut new_version = current.clone();
78
79        match bump_type {
80            BumpType::Major => {
81                new_version.major += 1;
82                new_version.minor = 0;
83                new_version.patch = 0;
84                new_version.pre = semver::Prerelease::EMPTY;
85            }
86            BumpType::Minor => {
87                new_version.minor += 1;
88                new_version.patch = 0;
89                new_version.pre = semver::Prerelease::EMPTY;
90            }
91            BumpType::Patch => {
92                new_version.patch += 1;
93                new_version.pre = semver::Prerelease::EMPTY;
94            }
95            BumpType::Prerelease => {
96                if new_version.pre.is_empty() {
97                    // Start with alpha.1
98                    new_version.pre = "alpha.1".parse()?;
99                } else {
100                    // Increment prerelease number
101                    let pre_str = new_version.pre.as_str();
102
103                    // Parse "alpha.1" -> increment to "alpha.2"
104                    if let Some((prefix, num_str)) = pre_str.rsplit_once('.') {
105                        if let Ok(num) = num_str.parse::<u64>() {
106                            new_version.pre = format!("{}.{}", prefix, num + 1).parse()?;
107                        } else {
108                            // No number, add .1
109                            new_version.pre = format!("{}.1", pre_str).parse()?;
110                        }
111                    } else {
112                        // No dot, add .1
113                        new_version.pre = format!("{}.1", pre_str).parse()?;
114                    }
115                }
116            }
117        }
118
119        Ok(new_version)
120    }
121
122    fn update_dependencies(
123        &self,
124        manifests: &mut [CargoManifest],
125        changes: &[VersionChange],
126    ) -> Result<()> {
127        let version_map: HashMap<String, Version> = changes
128            .iter()
129            .map(|c| (c.package.clone(), c.new_version.clone()))
130            .collect();
131
132        for manifest in manifests {
133            // Collect dependency names that need updating
134            let deps_to_update: Vec<(String, Version)> = manifest
135                .embeddenator_dependencies()
136                .iter()
137                .filter_map(|dep| {
138                    version_map
139                        .get(&dep.name)
140                        .map(|new_version| (dep.name.clone(), new_version.clone()))
141                })
142                .collect();
143
144            // Now update them
145            for (dep_name, new_version) in deps_to_update {
146                manifest.update_dependency(&dep_name, &new_version)?;
147            }
148        }
149
150        Ok(())
151    }
152
153    /// Check for version inconsistencies across the workspace.
154    pub fn check_consistency(&self) -> Result<VersionReport> {
155        let manifests = self
156            .scanner
157            .find_embeddenator_packages()
158            .context("Failed to find packages")?;
159
160        let mut report = VersionReport::default();
161
162        // Track package versions
163        let package_versions: HashMap<String, Version> = manifests
164            .iter()
165            .map(|m| (m.package_name.clone(), m.version.clone()))
166            .collect();
167
168        // Check for version drift
169        let mut versions_by_major: HashMap<u64, Vec<&str>> = HashMap::new();
170        for (name, version) in &package_versions {
171            versions_by_major
172                .entry(version.major)
173                .or_default()
174                .push(name.as_str());
175        }
176
177        if versions_by_major.len() > 1 {
178            report.drift_detected = true;
179            for (major, packages) in versions_by_major {
180                report.issues.push(format!(
181                    "Version drift: {} package(s) on major version {}: {}",
182                    packages.len(),
183                    major,
184                    packages.join(", ")
185                ));
186            }
187        }
188
189        // Check dependency consistency
190        for manifest in &manifests {
191            for dep in manifest.embeddenator_dependencies() {
192                if let Some(dep_version) = &dep.version {
193                    if let Some(actual_version) = package_versions.get(&dep.name) {
194                        if dep_version != actual_version {
195                            report.inconsistencies.push(VersionInconsistency {
196                                package: manifest.package_name.clone(),
197                                dependency: dep.name.clone(),
198                                expected: actual_version.clone(),
199                                found: dep_version.clone(),
200                            });
201                        }
202                    }
203                }
204            }
205        }
206
207        report.total_packages = manifests.len();
208        Ok(report)
209    }
210}
211
212/// Represents a version change for a package.
213#[derive(Debug, Clone)]
214pub struct VersionChange {
215    pub package: String,
216    pub path: std::path::PathBuf,
217    pub old_version: Version,
218    pub new_version: Version,
219}
220
221/// Report of version consistency check.
222#[derive(Debug, Default)]
223pub struct VersionReport {
224    pub total_packages: usize,
225    pub drift_detected: bool,
226    pub issues: Vec<String>,
227    pub inconsistencies: Vec<VersionInconsistency>,
228}
229
230#[derive(Debug, Clone)]
231pub struct VersionInconsistency {
232    pub package: String,
233    pub dependency: String,
234    pub expected: Version,
235    pub found: Version,
236}
237
238impl VersionReport {
239    pub fn has_issues(&self) -> bool {
240        self.drift_detected || !self.inconsistencies.is_empty()
241    }
242}
243
244#[cfg(test)]
245#[path = "version_tests.rs"]
246mod tests;