ricecoder_github/managers/
release_manager.rs1use crate::errors::{GitHubError, Result};
4use crate::models::Release;
5use chrono::Utc;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use tracing::{debug, info};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct SemanticVersion {
13 pub major: u32,
15 pub minor: u32,
17 pub patch: u32,
19 pub prerelease: Option<String>,
21 pub build: Option<String>,
23}
24
25impl SemanticVersion {
26 pub fn new(major: u32, minor: u32, patch: u32) -> Self {
28 Self {
29 major,
30 minor,
31 patch,
32 prerelease: None,
33 build: None,
34 }
35 }
36
37 pub fn parse(version_str: &str) -> Result<Self> {
39 let version_str = version_str.trim_start_matches('v');
40
41 let (version_part, build) = if let Some(pos) = version_str.find('+') {
43 (
44 &version_str[..pos],
45 Some(version_str[pos + 1..].to_string()),
46 )
47 } else {
48 (version_str, None)
49 };
50
51 let (version_part, prerelease) = if let Some(pos) = version_part.find('-') {
53 (
54 &version_part[..pos],
55 Some(version_part[pos + 1..].to_string()),
56 )
57 } else {
58 (version_part, None)
59 };
60
61 let parts: Vec<&str> = version_part.split('.').collect();
63 if parts.len() != 3 {
64 return Err(GitHubError::invalid_input(
65 "Invalid semantic version format. Expected major.minor.patch",
66 ));
67 }
68
69 let major = parts[0]
70 .parse::<u32>()
71 .map_err(|_| GitHubError::invalid_input("Invalid major version"))?;
72 let minor = parts[1]
73 .parse::<u32>()
74 .map_err(|_| GitHubError::invalid_input("Invalid minor version"))?;
75 let patch = parts[2]
76 .parse::<u32>()
77 .map_err(|_| GitHubError::invalid_input("Invalid patch version"))?;
78
79 Ok(Self {
80 major,
81 minor,
82 patch,
83 prerelease,
84 build,
85 })
86 }
87
88 pub fn to_tag(&self) -> String {
90 format!("v{}", self)
91 }
92
93 pub fn bump_major(&self) -> Self {
95 Self {
96 major: self.major + 1,
97 minor: 0,
98 patch: 0,
99 prerelease: None,
100 build: None,
101 }
102 }
103
104 pub fn bump_minor(&self) -> Self {
106 Self {
107 major: self.major,
108 minor: self.minor + 1,
109 patch: 0,
110 prerelease: None,
111 build: None,
112 }
113 }
114
115 pub fn bump_patch(&self) -> Self {
117 Self {
118 major: self.major,
119 minor: self.minor,
120 patch: self.patch + 1,
121 prerelease: None,
122 build: None,
123 }
124 }
125}
126
127impl std::fmt::Display for SemanticVersion {
128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 let mut version = format!("{}.{}.{}", self.major, self.minor, self.patch);
130 if let Some(prerelease) = &self.prerelease {
131 version.push('-');
132 version.push_str(prerelease);
133 }
134 if let Some(build) = &self.build {
135 version.push('+');
136 version.push_str(build);
137 }
138 write!(f, "{}", version)
139 }
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct ReleaseOptions {
145 pub tag_name: String,
147 pub name: String,
149 pub body: String,
151 pub draft: bool,
153 pub prerelease: bool,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct ReleaseNotesOptions {
160 pub previous_tag: Option<String>,
162 pub include_commits: bool,
164 pub include_prs: bool,
166 pub include_contributors: bool,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct ReleaseHistoryEntry {
173 pub version: String,
175 pub date: chrono::DateTime<Utc>,
177 pub notes: String,
179 pub prerelease: bool,
181}
182
183#[derive(Debug, Clone)]
185pub struct ReleaseManager {
186 history: HashMap<String, ReleaseHistoryEntry>,
188}
189
190impl ReleaseManager {
191 pub fn new() -> Self {
193 Self {
194 history: HashMap::new(),
195 }
196 }
197
198 pub async fn create_release(&mut self, options: ReleaseOptions) -> Result<Release> {
200 debug!("Creating release: {}", options.tag_name);
201
202 if !options.tag_name.starts_with('v') {
204 return Err(GitHubError::invalid_input(
205 "Tag name must start with 'v'",
206 ));
207 }
208
209 let version_str = options.tag_name.trim_start_matches('v');
211 let _version = SemanticVersion::parse(version_str)?;
212
213 let release = Release {
215 id: 0, tag_name: options.tag_name.clone(),
217 name: options.name.clone(),
218 body: options.body.clone(),
219 draft: options.draft,
220 prerelease: options.prerelease,
221 created_at: Utc::now(),
222 };
223
224 self.history.insert(
226 options.tag_name.clone(),
227 ReleaseHistoryEntry {
228 version: version_str.to_string(),
229 date: Utc::now(),
230 notes: options.body.clone(),
231 prerelease: options.prerelease,
232 },
233 );
234
235 info!("Release created: {}", options.tag_name);
236 Ok(release)
237 }
238
239 pub async fn generate_release_notes(
241 &self,
242 options: ReleaseNotesOptions,
243 ) -> Result<String> {
244 debug!("Generating release notes");
245
246 let mut notes = String::new();
247
248 if options.include_commits {
249 notes.push_str("## Commits\n\n");
250 notes.push_str("- Commits since previous release\n\n");
251 }
252
253 if options.include_prs {
254 notes.push_str("## Pull Requests\n\n");
255 notes.push_str("- PRs merged since previous release\n\n");
256 }
257
258 if options.include_contributors {
259 notes.push_str("## Contributors\n\n");
260 notes.push_str("- Contributors to this release\n\n");
261 }
262
263 if notes.is_empty() {
264 notes.push_str("## Release Notes\n\nNo changes documented.\n");
265 }
266
267 info!("Release notes generated");
268 Ok(notes)
269 }
270
271 pub async fn publish_release(&mut self, release: Release) -> Result<Release> {
273 debug!("Publishing release: {}", release.tag_name);
274
275 if release.tag_name.is_empty() {
277 return Err(GitHubError::invalid_input("Release tag name cannot be empty"));
278 }
279
280 if release.name.is_empty() {
281 return Err(GitHubError::invalid_input("Release name cannot be empty"));
282 }
283
284 let version_str = release.tag_name.trim_start_matches('v');
286 self.history.insert(
287 release.tag_name.clone(),
288 ReleaseHistoryEntry {
289 version: version_str.to_string(),
290 date: release.created_at,
291 notes: release.body.clone(),
292 prerelease: release.prerelease,
293 },
294 );
295
296 info!("Release published: {}", release.tag_name);
297 Ok(release)
298 }
299
300 pub fn get_release_history(&self) -> Vec<ReleaseHistoryEntry> {
302 let mut entries: Vec<_> = self.history.values().cloned().collect();
303 entries.sort_by(|a, b| b.date.cmp(&a.date));
304 entries
305 }
306
307 pub fn get_release(&self, tag_name: &str) -> Option<ReleaseHistoryEntry> {
309 self.history.get(tag_name).cloned()
310 }
311
312 pub fn generate_changelog(&self) -> String {
314 let mut changelog = String::from("# Changelog\n\n");
315
316 let mut entries: Vec<_> = self.history.values().cloned().collect();
317 entries.sort_by(|a, b| b.date.cmp(&a.date));
318
319 for entry in entries {
320 changelog.push_str(&format!("## [{}] - {}\n\n", entry.version, entry.date.format("%Y-%m-%d")));
321 changelog.push_str(&entry.notes);
322 changelog.push_str("\n\n");
323 }
324
325 changelog
326 }
327
328 pub fn validate_version_tag(tag: &str) -> Result<SemanticVersion> {
330 if !tag.starts_with('v') {
331 return Err(GitHubError::invalid_input(
332 "Version tag must start with 'v'",
333 ));
334 }
335
336 let version_str = tag.trim_start_matches('v');
337 SemanticVersion::parse(version_str)
338 }
339
340 pub fn version_exists(&self, tag_name: &str) -> bool {
342 self.history.contains_key(tag_name)
343 }
344
345 pub fn get_latest_release(&self) -> Option<ReleaseHistoryEntry> {
347 let mut entries: Vec<_> = self.history.values().cloned().collect();
348 entries.sort_by(|a, b| b.date.cmp(&a.date));
349 entries.first().cloned()
350 }
351}
352
353impl Default for ReleaseManager {
354 fn default() -> Self {
355 Self::new()
356 }
357}