cargo_version_info/
github.rs1use std::env;
4
5use anyhow::{
6 Context,
7 Result,
8};
9
10use crate::version::{
11 format_version,
12 increment_patch,
13 parse_version,
14};
15
16#[allow(clippy::disallowed_methods)] pub async fn get_latest_release_version(
23 owner: &str,
24 repo: &str,
25 github_token: Option<&str>,
26) -> Result<Option<String>> {
27 let env_token = env::var("GITHUB_TOKEN").ok();
29 let token = github_token.or(env_token.as_deref());
30
31 let result = if let Some(token) = token {
34 get_latest_release_via_api(owner, repo, Some(token)).await
35 } else {
36 get_latest_release_via_api(owner, repo, None).await
38 };
39
40 match result {
41 Ok(version) => Ok(Some(version)),
42 Err(e) => {
43 let error_msg = e.to_string();
44 if error_msg.contains("No releases found") {
46 Ok(None)
47 } else if error_msg.contains("404") || error_msg.contains("Not Found") {
48 if token.is_none() {
50 Err(anyhow::anyhow!(
51 "Repository not found or is private. For private repositories, \
52 set GITHUB_TOKEN environment variable or pass --github-token"
53 )
54 .context(error_msg))
55 } else {
56 Err(e)
57 }
58 } else if error_msg.contains("403") || error_msg.contains("Forbidden") {
59 Err(anyhow::anyhow!(
61 "Access forbidden. This may be a private repository. \
62 Ensure GITHUB_TOKEN has appropriate permissions."
63 )
64 .context(error_msg))
65 } else {
66 Err(e)
67 }
68 }
69 }
70}
71
72async fn get_latest_release_via_api(
77 owner: &str,
78 repo: &str,
79 token: Option<&str>,
80) -> Result<String> {
81 let octocrab = if let Some(token) = token {
82 octocrab::OctocrabBuilder::new()
83 .personal_token(token.to_string())
84 .build()
85 .context("Failed to create GitHub API client")?
86 } else {
87 octocrab::Octocrab::builder()
89 .build()
90 .context("Failed to create GitHub API client")?
91 };
92
93 let releases = octocrab
94 .repos(owner, repo)
95 .releases()
96 .list()
97 .per_page(1)
98 .send()
99 .await
100 .context("Failed to query GitHub releases")?;
101
102 let release = releases.items.first().context("No releases found")?;
103
104 let tag_name = release.tag_name.as_str();
105 let version = tag_name.strip_prefix('v').unwrap_or(tag_name);
106 let version = version.strip_prefix('V').unwrap_or(version);
107
108 Ok(version.to_string())
109}
110
111fn get_latest_git_tag_version() -> Result<Option<String>> {
116 let cwd = std::env::current_dir().context("Failed to get current directory")?;
117 let repo = gix::discover(cwd)
118 .context("Failed to discover git repository. Ensure you're in a git repository.")?;
119
120 let mut version_tags: Vec<(String, (u32, u32, u32))> = repo
121 .references()?
122 .prefixed("refs/tags/")?
123 .filter_map(|r: Result<gix::Reference<'_>, _>| r.ok())
124 .filter_map(|r| {
125 let name_full = r.name().as_bstr().to_string();
126 let name = name_full.strip_prefix("refs/tags/").unwrap_or(&name_full);
127 let version_str = name
128 .strip_prefix('v')
129 .or_else(|| name.strip_prefix('V'))
130 .unwrap_or(name);
131
132 if let Ok((major, minor, patch)) = parse_version(version_str) {
134 Some((name.to_string(), (major, minor, patch)))
135 } else {
136 None
137 }
138 })
139 .collect();
140
141 version_tags.sort_by_key(|a| a.1);
143
144 Ok(version_tags
145 .last()
146 .map(|(tag_name, _): &(String, (u32, u32, u32))| {
147 tag_name
148 .strip_prefix('v')
149 .or_else(|| tag_name.strip_prefix('V'))
150 .unwrap_or(tag_name)
151 .to_string()
152 }))
153}
154
155pub async fn calculate_next_version(
161 _owner: &str,
162 _repo: &str,
163 _github_token: Option<&str>,
164) -> Result<(String, String)> {
165 let latest_version_str = match get_latest_git_tag_version()? {
167 Some(v) => v,
168 None => {
169 return Ok(("0.0.0".to_string(), "0.0.1".to_string()));
171 }
172 };
173
174 let (major, minor, patch) = parse_version(&latest_version_str)
175 .with_context(|| format!("Failed to parse latest version: {}", latest_version_str))?;
176
177 let (major, minor, patch) = increment_patch(major, minor, patch);
178 let next_version = format_version(major, minor, patch);
179
180 Ok((latest_version_str, next_version))
181}
182
183#[cfg(test)]
184mod tests {
185 use std::process::Command;
186
187 use super::*;
188
189 async fn create_test_git_repo_with_tags(tags: &[&str]) -> async_fs_io::TempDir {
190 let dir = async_fs_io::TempDir::create(std::env::temp_dir())
191 .await
192 .unwrap();
193
194 Command::new("git")
196 .arg("init")
197 .current_dir(dir.path())
198 .output()
199 .unwrap();
200
201 Command::new("git")
202 .args(["config", "user.email", "test@example.com"])
203 .current_dir(dir.path())
204 .output()
205 .unwrap();
206
207 Command::new("git")
208 .args(["config", "user.name", "Test User"])
209 .current_dir(dir.path())
210 .output()
211 .unwrap();
212
213 async_fs_io::write_bytes(dir.path().join("README.md"), b"# Test\n")
215 .await
216 .unwrap();
217 Command::new("git")
218 .args(["add", "README.md"])
219 .current_dir(dir.path())
220 .output()
221 .unwrap();
222
223 Command::new("git")
224 .args(["commit", "-m", "Initial commit"])
225 .current_dir(dir.path())
226 .output()
227 .unwrap();
228
229 for tag in tags {
231 Command::new("git")
232 .args(["tag", "-a", tag, "-m", &format!("Release {}", tag)])
233 .current_dir(dir.path())
234 .output()
235 .unwrap();
236 }
237
238 dir
239 }
240
241 #[tokio::test]
242 async fn test_get_latest_git_tag_version_no_tags() {
243 let dir = create_test_git_repo_with_tags(&[]).await;
244 let original_dir = std::env::current_dir().unwrap();
245
246 std::env::set_current_dir(dir.path()).unwrap();
247 let result = get_latest_git_tag_version().unwrap();
248 std::env::set_current_dir(original_dir).unwrap();
249
250 assert_eq!(result, None);
251 }
252
253 #[tokio::test]
254 async fn test_get_latest_git_tag_version_single_tag() {
255 let _dir = create_test_git_repo_with_tags(&["v0.1.0"]).await;
256 let dir_path = _dir.path().to_path_buf();
257 let original_dir = std::env::current_dir().unwrap();
258
259 std::env::set_current_dir(&dir_path).unwrap();
260 let result = get_latest_git_tag_version().unwrap();
261 std::env::set_current_dir(original_dir).unwrap();
262
263 assert_eq!(result, Some("0.1.0".to_string()));
264 }
265
266 #[tokio::test]
267 async fn test_get_latest_git_tag_version_multiple_tags() {
268 let _dir = create_test_git_repo_with_tags(&["v0.1.0", "v0.2.0", "v0.1.5"]).await;
269 let dir_path = _dir.path().to_path_buf();
270 let original_dir = std::env::current_dir().unwrap();
271
272 std::env::set_current_dir(&dir_path).unwrap();
273 let result = get_latest_git_tag_version().unwrap();
274 std::env::set_current_dir(original_dir).unwrap();
275
276 assert_eq!(result, Some("0.2.0".to_string()));
278 }
279
280 #[tokio::test]
281 async fn test_get_latest_git_tag_version_without_v_prefix() {
282 let _dir = create_test_git_repo_with_tags(&["0.3.0", "v0.2.0"]).await;
283 let dir_path = _dir.path().to_path_buf();
284 let original_dir = std::env::current_dir().unwrap();
285
286 std::env::set_current_dir(&dir_path).unwrap();
287 let result = get_latest_git_tag_version().unwrap();
288 std::env::set_current_dir(original_dir).unwrap();
289
290 assert_eq!(result, Some("0.3.0".to_string()));
292 }
293
294 #[tokio::test]
295 async fn test_calculate_next_version_no_tags() {
296 let _dir = create_test_git_repo_with_tags(&[]).await;
297 let dir_path = _dir.path().to_path_buf();
298 let original_dir = std::env::current_dir().unwrap();
299
300 std::env::set_current_dir(&dir_path).unwrap();
301 let (latest, next) = calculate_next_version("test", "repo", None).await.unwrap();
302 std::env::set_current_dir(original_dir).unwrap();
303
304 assert_eq!(latest, "0.0.0");
305 assert_eq!(next, "0.0.1");
306 }
307
308 #[tokio::test]
309 async fn test_calculate_next_version_with_tags() {
310 let _dir = create_test_git_repo_with_tags(&["v0.1.2"]).await;
311 let dir_path = _dir.path().to_path_buf();
312 let original_dir = std::env::current_dir().unwrap();
313
314 std::env::set_current_dir(&dir_path).unwrap();
315 let (latest, next) = calculate_next_version("test", "repo", None).await.unwrap();
316 std::env::set_current_dir(original_dir).unwrap();
317
318 assert_eq!(latest, "0.1.2");
319 assert_eq!(next, "0.1.3");
320 }
321
322 #[tokio::test]
323 #[ignore] async fn test_get_latest_release_via_api() {
325 if let Ok(Some(version)) = get_latest_release_version("rust-lang", "rust", None).await {
328 println!("Latest rust release: {}", version);
329 }
330 }
331}