Skip to main content

cargo_version_info/
github.rs

1//! GitHub API integration for version queries.
2
3use 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/// Get the latest published release version from GitHub.
17///
18/// Uses the GitHub API via octocrab. Works for public repos without a token
19/// (with rate limits). For private repos, a token is required (automatically
20/// detected from GITHUB_TOKEN env var if not provided).
21#[allow(clippy::disallowed_methods)] // CLI tool needs direct env access
22pub async fn get_latest_release_version(
23    owner: &str,
24    repo: &str,
25    github_token: Option<&str>,
26) -> Result<Option<String>> {
27    // Auto-detect token from environment if not provided
28    let env_token = env::var("GITHUB_TOKEN").ok();
29    let token = github_token.or(env_token.as_deref());
30
31    // Try with token first (required for private repos, better rate limits for
32    // public)
33    let result = if let Some(token) = token {
34        get_latest_release_via_api(owner, repo, Some(token)).await
35    } else {
36        // Try without token (public repos only)
37        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 no releases found, return None instead of error
45            if error_msg.contains("No releases found") {
46                Ok(None)
47            } else if error_msg.contains("404") || error_msg.contains("Not Found") {
48                // 404 could mean private repo without auth or repo doesn't exist
49                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                // 403 usually means private repo or rate limit
60                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
72/// Get latest release via GitHub API.
73///
74/// Works for public repositories even without a token (with rate limits).
75/// If a token is provided, uses it for authentication (higher rate limits).
76async 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        // For public repos, we can use octocrab without a token
88        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
111/// Get the latest version from git tags.
112///
113/// Queries git tags in the current repository to find the latest semantic
114/// version tag. Returns None if no version tags exist.
115fn 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            // Try to parse as semantic version
133            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    // Sort tags by semantic version (major, minor, patch)
142    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
155/// Calculate next patch version from latest git tag.
156///
157/// Queries git tags in the current repository (not GitHub releases) to find
158/// the latest version. If no tags exist, returns "0.0.0" as latest and
159/// "0.0.1" as next.
160pub async fn calculate_next_version(
161    _owner: &str,
162    _repo: &str,
163    _github_token: Option<&str>,
164) -> Result<(String, String)> {
165    // Get latest version from git tags (not GitHub releases)
166    let latest_version_str = match get_latest_git_tag_version()? {
167        Some(v) => v,
168        None => {
169            // No tags yet, start at 0.0.1
170            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 tempfile::TempDir;
188
189    use super::*;
190
191    fn create_test_git_repo_with_tags(tags: &[&str]) -> TempDir {
192        let dir = tempfile::tempdir().unwrap();
193
194        // Initialize git repo
195        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        // Create an initial commit
214        std::fs::write(dir.path().join("README.md"), "# Test\n").unwrap();
215        Command::new("git")
216            .args(["add", "README.md"])
217            .current_dir(dir.path())
218            .output()
219            .unwrap();
220
221        Command::new("git")
222            .args(["commit", "-m", "Initial commit"])
223            .current_dir(dir.path())
224            .output()
225            .unwrap();
226
227        // Create tags
228        for tag in tags {
229            Command::new("git")
230                .args(["tag", "-a", tag, "-m", &format!("Release {}", tag)])
231                .current_dir(dir.path())
232                .output()
233                .unwrap();
234        }
235
236        dir
237    }
238
239    #[test]
240    fn test_get_latest_git_tag_version_no_tags() {
241        let dir = create_test_git_repo_with_tags(&[]);
242        let original_dir = std::env::current_dir().unwrap();
243
244        std::env::set_current_dir(dir.path()).unwrap();
245        let result = get_latest_git_tag_version().unwrap();
246        std::env::set_current_dir(original_dir).unwrap();
247
248        assert_eq!(result, None);
249    }
250
251    #[test]
252    fn test_get_latest_git_tag_version_single_tag() {
253        let _dir = create_test_git_repo_with_tags(&["v0.1.0"]);
254        let dir_path = _dir.path().to_path_buf();
255        let original_dir = std::env::current_dir().unwrap();
256
257        std::env::set_current_dir(&dir_path).unwrap();
258        let result = get_latest_git_tag_version().unwrap();
259        std::env::set_current_dir(original_dir).unwrap();
260
261        assert_eq!(result, Some("0.1.0".to_string()));
262    }
263
264    #[test]
265    fn test_get_latest_git_tag_version_multiple_tags() {
266        let _dir = create_test_git_repo_with_tags(&["v0.1.0", "v0.2.0", "v0.1.5"]);
267        let dir_path = _dir.path().to_path_buf();
268        let original_dir = std::env::current_dir().unwrap();
269
270        std::env::set_current_dir(&dir_path).unwrap();
271        let result = get_latest_git_tag_version().unwrap();
272        std::env::set_current_dir(original_dir).unwrap();
273
274        // Should return the latest version (0.2.0)
275        assert_eq!(result, Some("0.2.0".to_string()));
276    }
277
278    #[test]
279    fn test_get_latest_git_tag_version_without_v_prefix() {
280        let _dir = create_test_git_repo_with_tags(&["0.3.0", "v0.2.0"]);
281        let dir_path = _dir.path().to_path_buf();
282        let original_dir = std::env::current_dir().unwrap();
283
284        std::env::set_current_dir(&dir_path).unwrap();
285        let result = get_latest_git_tag_version().unwrap();
286        std::env::set_current_dir(original_dir).unwrap();
287
288        // Should return the latest version (0.3.0)
289        assert_eq!(result, Some("0.3.0".to_string()));
290    }
291
292    #[tokio::test]
293    async fn test_calculate_next_version_no_tags() {
294        let _dir = create_test_git_repo_with_tags(&[]);
295        let dir_path = _dir.path().to_path_buf();
296        let original_dir = std::env::current_dir().unwrap();
297
298        std::env::set_current_dir(&dir_path).unwrap();
299        let (latest, next) = calculate_next_version("test", "repo", None).await.unwrap();
300        std::env::set_current_dir(original_dir).unwrap();
301
302        assert_eq!(latest, "0.0.0");
303        assert_eq!(next, "0.0.1");
304    }
305
306    #[tokio::test]
307    async fn test_calculate_next_version_with_tags() {
308        let _dir = create_test_git_repo_with_tags(&["v0.1.2"]);
309        let dir_path = _dir.path().to_path_buf();
310        let original_dir = std::env::current_dir().unwrap();
311
312        std::env::set_current_dir(&dir_path).unwrap();
313        let (latest, next) = calculate_next_version("test", "repo", None).await.unwrap();
314        std::env::set_current_dir(original_dir).unwrap();
315
316        assert_eq!(latest, "0.1.2");
317        assert_eq!(next, "0.1.3");
318    }
319
320    #[tokio::test]
321    #[ignore] // Requires network access
322    async fn test_get_latest_release_via_api() {
323        // This test requires network access
324        // Only run manually
325        if let Ok(Some(version)) = get_latest_release_version("rust-lang", "rust", None).await {
326            println!("Latest rust release: {}", version);
327        }
328    }
329}