polykit_core/
release.rs

1//! Semantic versioning and release management.
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6use rayon::prelude::*;
7use semver::Version;
8
9use crate::adapter::LanguageAdapter;
10use crate::error::{Error, Result};
11use crate::graph::DependencyGraph;
12use crate::release_reporter::ReleaseReporter;
13
14type AdapterGetter =
15    Box<dyn Fn(&crate::package::Language) -> Box<dyn LanguageAdapter> + Send + Sync>;
16
17/// Engine for planning and executing releases.
18pub struct ReleaseEngine {
19    packages_dir: PathBuf,
20    graph: DependencyGraph,
21    dry_run: bool,
22    adapter_getter: AdapterGetter,
23    reporter: Box<dyn ReleaseReporter>,
24}
25
26/// A plan for releasing packages with version bumps.
27#[derive(Debug, Clone)]
28pub struct ReleasePlan {
29    /// Packages that will be version-bumped.
30    pub packages: Vec<ReleasePackage>,
31}
32
33/// A package that will be version-bumped as part of a release.
34#[derive(Debug, Clone)]
35pub struct ReleasePackage {
36    /// Package name.
37    pub name: String,
38    /// Old version (if it existed).
39    pub old_version: Option<String>,
40    /// New version after bump.
41    pub new_version: String,
42    /// Type of version bump.
43    pub bump_type: BumpType,
44}
45
46/// Type of semantic version bump.
47#[derive(Debug, Clone, Copy)]
48pub enum BumpType {
49    /// Major version bump (1.0.0 -> 2.0.0).
50    Major,
51    /// Minor version bump (1.0.0 -> 1.1.0).
52    Minor,
53    /// Patch version bump (1.0.0 -> 1.0.1).
54    Patch,
55}
56
57impl ReleaseEngine {
58    /// Creates a new release engine.
59    ///
60    /// If `dry_run` is `true`, version bumps will be planned but not executed.
61    ///
62    /// The `adapter_getter` function is used to obtain language adapters for reading
63    /// and updating package metadata.
64    ///
65    /// The `reporter` is used to report version bump operations without directly
66    /// writing to stdout/stderr.
67    pub fn new<F, R>(
68        packages_dir: impl Into<PathBuf>,
69        graph: DependencyGraph,
70        dry_run: bool,
71        adapter_getter: F,
72        reporter: R,
73    ) -> Self
74    where
75        F: Fn(&crate::package::Language) -> Box<dyn LanguageAdapter> + Send + Sync + 'static,
76        R: ReleaseReporter + 'static,
77    {
78        Self {
79            packages_dir: packages_dir.into(),
80            graph,
81            dry_run,
82            adapter_getter: Box::new(adapter_getter),
83            reporter: Box::new(reporter),
84        }
85    }
86
87    /// Plans a release by bumping the specified package and updating dependents.
88    ///
89    /// The release plan includes:
90    /// - The target package with the requested bump type
91    /// - Dependent packages that need patch bumps
92    ///
93    /// # Errors
94    ///
95    /// Returns an error if the package is not found or version operations fail.
96    pub fn plan_release(&self, package_name: &str, bump_type: BumpType) -> Result<ReleasePlan> {
97        let order = self.graph.topological_order();
98        let mut plan = ReleasePlan {
99            packages: Vec::new(),
100        };
101
102        let available: Vec<String> = self
103            .graph
104            .all_packages()
105            .iter()
106            .map(|p| p.name.clone())
107            .collect();
108        let available_str = available.join(", ");
109        let target_idx = order
110            .iter()
111            .position(|n| n == package_name)
112            .ok_or_else(|| Error::PackageNotFound {
113                name: package_name.to_string(),
114                available: available_str.clone(),
115            })?;
116
117        // Collect all packages and their paths for parallel metadata reading
118        let packages_with_paths: Vec<(String, PathBuf, crate::package::Language)> = order
119            .iter()
120            .filter_map(|name| {
121                self.graph.get_package(name).map(|pkg| {
122                    (
123                        name.clone(),
124                        self.packages_dir.join(&pkg.path),
125                        pkg.language,
126                    )
127                })
128            })
129            .collect();
130
131        // Read all metadata in parallel
132        let metadata_results: Result<Vec<(String, Option<String>)>> = packages_with_paths
133            .into_par_iter()
134            .map(|(name, path, language)| {
135                let adapter = (self.adapter_getter)(&language);
136                let metadata = adapter.read_metadata(&path)?;
137                Ok((name, metadata.version))
138            })
139            .collect();
140
141        let metadata_map: HashMap<String, Option<String>> =
142            metadata_results?.into_iter().collect::<HashMap<_, _>>();
143
144        let mut versions = HashMap::new();
145
146        // Process packages sequentially for version calculations (depends on previous results)
147        for (idx, package_name) in order.iter().enumerate() {
148            let old_version =
149                metadata_map
150                    .get(package_name)
151                    .cloned()
152                    .ok_or_else(|| Error::PackageNotFound {
153                        name: package_name.clone(),
154                        available: available_str.clone(),
155                    })?;
156
157            let new_version = if idx == target_idx {
158                self.bump_version(&old_version, bump_type)?
159            } else if idx < target_idx {
160                let deps = self.graph.dependencies(package_name)?;
161                let current_version = old_version.as_deref().unwrap_or("0.1.0");
162                let needs_bump = deps.iter().any(|dep| {
163                    versions
164                        .get(dep)
165                        .map(|v: &String| v != current_version)
166                        .unwrap_or(false)
167                });
168
169                if needs_bump {
170                    self.bump_version(&old_version, BumpType::Patch)?
171                } else {
172                    old_version.clone().unwrap_or_else(|| "0.1.0".to_string())
173                }
174            } else {
175                old_version.clone().unwrap_or_else(|| "0.1.0".to_string())
176            };
177
178            versions.insert(package_name.clone(), new_version.clone());
179
180            if let Some(old) = &old_version {
181                if old != &new_version {
182                    plan.packages.push(ReleasePackage {
183                        name: package_name.clone(),
184                        old_version: old_version.clone(),
185                        new_version,
186                        bump_type: if idx == target_idx {
187                            bump_type
188                        } else {
189                            BumpType::Patch
190                        },
191                    });
192                }
193            } else if idx <= target_idx {
194                plan.packages.push(ReleasePackage {
195                    name: package_name.clone(),
196                    old_version: None,
197                    new_version,
198                    bump_type: if idx == target_idx {
199                        bump_type
200                    } else {
201                        BumpType::Patch
202                    },
203                });
204            }
205        }
206
207        Ok(plan)
208    }
209
210    /// Executes a release plan by updating version numbers in package files.
211    ///
212    /// If `dry_run` is enabled, this will only report what would be changed
213    /// without actually modifying files.
214    ///
215    /// # Errors
216    ///
217    /// Returns an error if version bumping fails for any package.
218    pub fn execute_release(&self, plan: &ReleasePlan) -> Result<()> {
219        for release_pkg in &plan.packages {
220            let available: Vec<String> = self
221                .graph
222                .all_packages()
223                .iter()
224                .map(|p| p.name.clone())
225                .collect();
226            let available_str = available.join(", ");
227            let package = self.graph.get_package(&release_pkg.name).ok_or_else(|| {
228                Error::PackageNotFound {
229                    name: release_pkg.name.clone(),
230                    available: available_str,
231                }
232            })?;
233
234            let adapter = (self.adapter_getter)(&package.language);
235            let package_path = self.packages_dir.join(&package.path);
236
237            if !self.dry_run {
238                adapter.bump_version(&package_path, &release_pkg.new_version)?;
239            }
240
241            self.reporter.report_bump(
242                &release_pkg.name,
243                release_pkg.old_version.as_deref(),
244                &release_pkg.new_version,
245                self.dry_run,
246            );
247        }
248
249        Ok(())
250    }
251
252    fn bump_version(&self, current: &Option<String>, bump_type: BumpType) -> Result<String> {
253        let version = if let Some(v) = current {
254            Version::parse(v)
255                .map_err(|e| Error::Release(format!("Invalid version {}: {}", v, e)))?
256        } else {
257            Version::parse("0.1.0")
258                .map_err(|e| Error::Release(format!("Failed to parse default version: {}", e)))?
259        };
260
261        let new_version = match bump_type {
262            BumpType::Major => Version::new(version.major + 1, 0, 0),
263            BumpType::Minor => Version::new(version.major, version.minor + 1, 0),
264            BumpType::Patch => Version::new(version.major, version.minor, version.patch + 1),
265        };
266
267        Ok(new_version.to_string())
268    }
269}