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 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        // 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        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        // Create tags
230        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        // Should return the latest version (0.2.0)
277        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        // Should return the latest version (0.3.0)
291        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] // Requires network access
324    async fn test_get_latest_release_via_api() {
325        // This test requires network access
326        // Only run manually
327        if let Ok(Some(version)) = get_latest_release_version("rust-lang", "rust", None).await {
328            println!("Latest rust release: {}", version);
329        }
330    }
331}