cargo_plugin_utils/
common.rs

1//! Common helper functions shared across cargo plugins.
2
3use std::env;
4
5use anyhow::{
6    Context,
7    Result,
8};
9use cargo_metadata::MetadataCommand;
10
11/// Detect GitHub repository from environment or git remote.
12#[allow(clippy::disallowed_methods)] // CLI tool needs direct env access
13pub fn detect_repo() -> Result<(String, String)> {
14    // Try GITHUB_REPOSITORY env var first (set by GitHub Actions)
15    if let Ok(repo) = env::var("GITHUB_REPOSITORY") {
16        let parts: Vec<&str> = repo.split('/').collect();
17        if parts.len() == 2 {
18            return Ok((parts[0].to_string(), parts[1].to_string()));
19        }
20    }
21
22    // Try to detect from git remote
23    let repo = gix::discover(".").context("Failed to discover git repository")?;
24    let remote = repo
25        .find_default_remote(gix::remote::Direction::Fetch)
26        .context("Failed to find default remote")?
27        .context("No default remote found")?;
28
29    let remote_url = remote
30        .url(gix::remote::Direction::Fetch)
31        .context("Failed to get remote URL")?;
32
33    // Parse git@github.com:owner/repo.git or https://github.com/owner/repo.git
34    let url_str = remote_url.to_string();
35    if let Some(rest) = url_str.strip_prefix("git@github.com:") {
36        let rest_trimmed: &str = rest.strip_suffix(".git").unwrap_or(rest);
37        let parts: Vec<&str> = rest_trimmed.split('/').collect();
38        if parts.len() >= 2 {
39            return Ok((parts[0].to_string(), parts[1].to_string()));
40        }
41    } else if let Some(rest) = url_str.strip_prefix("https://github.com/") {
42        let rest_trimmed: &str = rest.strip_suffix(".git").unwrap_or(rest);
43        let parts: Vec<&str> = rest_trimmed.split('/').collect();
44        if parts.len() >= 2 {
45            return Ok((parts[0].to_string(), parts[1].to_string()));
46        }
47    }
48
49    anyhow::bail!(
50        "Could not detect GitHub repository. Set GITHUB_REPOSITORY or use --owner/--repo flags"
51    );
52}
53
54/// Get owner and repo from args or environment.
55pub fn get_owner_repo(owner: Option<String>, repo: Option<String>) -> Result<(String, String)> {
56    match (owner, repo) {
57        (Some(o), Some(r)) => Ok((o, r)),
58        (Some(_), None) | (None, Some(_)) => {
59            anyhow::bail!("Both --owner and --repo must be provided together");
60        }
61        (None, None) => detect_repo(),
62    }
63}
64
65/// Find the Cargo package using cargo_metadata.
66///
67/// This automatically respects Cargo's `--manifest-path` option when running
68/// as a cargo subcommand.
69///
70/// Returns the package that corresponds to the current context, in order:
71/// 1. Package whose directory matches the current working directory
72/// 2. Package whose manifest path matches `current_dir/Cargo.toml`
73/// 3. Root package (if workspace has a root package)
74/// 4. First default-member (if workspace has default-members configured)
75/// 5. Error if no package can be determined
76pub fn find_package(manifest_path: Option<&std::path::Path>) -> Result<cargo_metadata::Package> {
77    let mut cmd = MetadataCommand::new();
78    if let Some(path) = manifest_path {
79        cmd.manifest_path(path);
80    }
81
82    let metadata = cmd.exec().context("Failed to get cargo metadata")?;
83
84    // Try to find the package in the current working directory
85    let current_dir = std::env::current_dir().context("Failed to get current directory")?;
86
87    // Canonicalize current directory and all package directories, then find match
88    let canonical_current_dir = current_dir.canonicalize().ok();
89    let packages_with_dirs: Vec<_> = metadata
90        .packages
91        .iter()
92        .filter_map(|pkg| {
93            // Get the directory containing the manifest (package directory)
94            pkg.manifest_path
95                .as_std_path()
96                .parent()
97                .and_then(|p| p.canonicalize().ok())
98                .map(|p| (pkg.clone(), p))
99        })
100        .collect();
101
102    // Try to match current directory with a package directory
103    if let Some(ref canonical_current) = canonical_current_dir
104        && let Some((pkg, _)) = packages_with_dirs
105            .iter()
106            .find(|(_, pkg_dir)| pkg_dir == canonical_current)
107    {
108        return Ok(pkg.clone());
109    }
110
111    // Also try matching the manifest path directly (for cases where Cargo.toml is
112    // in current dir)
113    let current_manifest = current_dir.join("Cargo.toml");
114    let canonical_current_manifest = current_manifest.canonicalize().ok();
115    let packages_with_manifests: Vec<_> = metadata
116        .packages
117        .iter()
118        .filter_map(|pkg| {
119            pkg.manifest_path
120                .as_std_path()
121                .canonicalize()
122                .ok()
123                .map(|p| (pkg.clone(), p))
124        })
125        .collect();
126
127    if let Some(ref canonical) = canonical_current_manifest
128        && let Some((pkg, _)) = packages_with_manifests
129            .iter()
130            .find(|(_, pkg_path)| pkg_path == canonical)
131    {
132        return Ok(pkg.clone());
133    }
134
135    // Fallback to root package (workspace root or single package)
136    if let Some(root_package) = metadata.root_package() {
137        return Ok(root_package.clone());
138    }
139
140    // If we're in a workspace without a root package, check for default-members
141    // This follows cargo's behavior: use default-members if available
142    // workspace_default_members implements Deref<Target = [PackageId]>, so we can
143    // use it as a slice It may not be available in older Cargo versions, so we
144    // check if it's available first
145    if metadata.workspace_default_members.is_available()
146        && !metadata.workspace_default_members.is_empty()
147        && let Some(first_default_id) = metadata.workspace_default_members.first()
148        && let Some(default_package) = metadata
149            .packages
150            .iter()
151            .find(|pkg| &pkg.id == first_default_id)
152    {
153        return Ok(default_package.clone());
154    }
155
156    // If no default-members, we need to be in a package directory
157    anyhow::bail!(
158        "No package found in current directory. Run this command from a package directory, \
159         or use --manifest-path to specify a package."
160    )
161}
162
163/// Get package version from a specific manifest path using cargo_metadata.
164pub fn get_package_version_from_manifest(manifest_path: &std::path::Path) -> Result<String> {
165    let package = find_package(Some(manifest_path))?;
166    Ok(package.version.to_string())
167}
168
169/// Get cargo metadata for a workspace or package.
170///
171/// This is a convenience function that handles `--manifest-path` idiomatically.
172/// When running as a cargo subcommand, cargo passes `--manifest-path` to the
173/// subcommand, so this function handles it explicitly.
174pub fn get_metadata(manifest_path: Option<&std::path::Path>) -> Result<cargo_metadata::Metadata> {
175    let mut cmd = MetadataCommand::new();
176    if let Some(path) = manifest_path {
177        cmd.manifest_path(path);
178    }
179    cmd.exec().context("Failed to get cargo metadata")
180}
181
182/// Get all workspace packages.
183///
184/// Returns all packages in the workspace (supports both single-package projects
185/// and workspace projects with packages in crates/ or elsewhere).
186pub fn get_workspace_packages(
187    manifest_path: Option<&std::path::Path>,
188) -> Result<Vec<cargo_metadata::Package>> {
189    let metadata = get_metadata(manifest_path)?;
190    Ok(metadata.packages)
191}
192
193#[cfg(test)]
194mod tests {
195    use std::env;
196
197    use super::*;
198
199    #[test]
200    fn test_get_owner_repo_both_provided() {
201        let result = get_owner_repo(Some("owner".to_string()), Some("repo".to_string()));
202        assert!(result.is_ok());
203        assert_eq!(result.unwrap(), ("owner".to_string(), "repo".to_string()));
204    }
205
206    #[test]
207    fn test_get_owner_repo_only_owner() {
208        let result = get_owner_repo(Some("owner".to_string()), None);
209        assert!(result.is_err());
210        assert!(
211            result
212                .unwrap_err()
213                .to_string()
214                .contains("Both --owner and --repo must be provided")
215        );
216    }
217
218    #[test]
219    fn test_get_owner_repo_only_repo() {
220        let result = get_owner_repo(None, Some("repo".to_string()));
221        assert!(result.is_err());
222        assert!(
223            result
224                .unwrap_err()
225                .to_string()
226                .contains("Both --owner and --repo must be provided")
227        );
228    }
229
230    #[test]
231    fn test_get_owner_repo_from_env() {
232        // Save original value if it exists
233        let original = env::var("GITHUB_REPOSITORY").ok();
234
235        // Test GITHUB_REPOSITORY env var
236        unsafe {
237            env::set_var("GITHUB_REPOSITORY", "test-owner/test-repo");
238        }
239        let result = get_owner_repo(None, None);
240        assert!(result.is_ok());
241        assert_eq!(
242            result.unwrap(),
243            ("test-owner".to_string(), "test-repo".to_string())
244        );
245
246        // Restore original value
247        unsafe {
248            if let Some(val) = original {
249                env::set_var("GITHUB_REPOSITORY", &val);
250            } else {
251                env::remove_var("GITHUB_REPOSITORY");
252            }
253        }
254    }
255
256    #[test]
257    fn test_get_owner_repo_invalid_env() {
258        // Test invalid GITHUB_REPOSITORY format
259        unsafe {
260            env::set_var("GITHUB_REPOSITORY", "invalid");
261        }
262        let _result = get_owner_repo(None, None);
263        // Should fail if not in a git repo or invalid format
264        unsafe {
265            env::remove_var("GITHUB_REPOSITORY");
266        }
267    }
268
269    #[test]
270    fn test_find_package_in_current_dir() {
271        // This test requires being in a directory with a Cargo.toml
272        // We'll test that it doesn't panic, but actual success depends on environment
273        let result = find_package(None);
274        // Either succeeds (if in a cargo project) or fails with a descriptive error
275        if let Err(e) = result {
276            assert!(e.to_string().contains("package") || e.to_string().contains("manifest"));
277        }
278    }
279
280    #[test]
281    fn test_find_package_with_manifest_path() {
282        // Test with a non-existent manifest path
283        let result = find_package(Some(std::path::Path::new("/nonexistent/path/Cargo.toml")));
284        assert!(result.is_err());
285    }
286
287    #[test]
288    fn test_get_package_version_from_manifest() {
289        // Test with a non-existent manifest path
290        let result =
291            get_package_version_from_manifest(std::path::Path::new("/nonexistent/path/Cargo.toml"));
292        assert!(result.is_err());
293    }
294
295    #[test]
296    fn test_detect_repo_from_env() {
297        // Save original value if it exists
298        let original = env::var("GITHUB_REPOSITORY").ok();
299
300        unsafe {
301            env::set_var("GITHUB_REPOSITORY", "env-owner/env-repo");
302        }
303        let result = detect_repo();
304        // Should succeed because GITHUB_REPOSITORY is set and takes precedence
305        assert!(result.is_ok());
306        let (owner, repo) = result.unwrap();
307        assert_eq!(owner, "env-owner");
308        assert_eq!(repo, "env-repo");
309
310        // Restore original value
311        unsafe {
312            if let Some(val) = original {
313                env::set_var("GITHUB_REPOSITORY", &val);
314            } else {
315                env::remove_var("GITHUB_REPOSITORY");
316            }
317        }
318    }
319
320    #[test]
321    fn test_detect_repo_invalid_env_format() {
322        unsafe {
323            env::set_var("GITHUB_REPOSITORY", "invalid-format");
324        }
325        let _result = detect_repo();
326        // Should fail if not in a git repo
327        unsafe {
328            env::remove_var("GITHUB_REPOSITORY");
329        }
330    }
331}