cgx_core/cratespec.rs
1use std::path::PathBuf;
2
3use semver::VersionReq;
4use serde::{Deserialize, Serialize};
5use snafu::{OptionExt, ResultExt};
6use url::Url;
7
8use crate::{
9 Result,
10 config::{Config, ToolConfig, ToolConfigDetailed},
11 error,
12 git::GitSelector,
13};
14
15/// The source from which to obtain a crate.
16#[derive(Clone, Debug, Default, Eq, PartialEq)]
17pub enum Source {
18 /// No explicit source selector; resolve via config if present, ultimately defaulting to
19 /// crates.io absent explicit config override.
20 #[default]
21 Default,
22 /// `--git <URL>`
23 Git { url: String },
24 /// `--registry <NAME>`
25 Registry { name: String },
26 /// `--index <URL>`
27 Index { url: String },
28 /// `--path <PATH>`
29 Path { path: PathBuf },
30 /// `--github <owner/repo>`, with optional `--github-url`
31 GitHub {
32 repo: String,
33 custom_url: Option<String>,
34 },
35 /// `--gitlab <owner/repo>`, with optional `--gitlab-url`
36 GitLab {
37 repo: String,
38 custom_url: Option<String>,
39 },
40}
41
42impl Source {
43 /// True for git-backed sources, the only sources a non-default [`GitSelector`] may accompany.
44 pub fn is_git(&self) -> bool {
45 matches!(
46 self,
47 Source::Git { .. } | Source::GitHub { .. } | Source::GitLab { .. }
48 )
49 }
50
51 /// True when a crate name can be discovered from the source itself, making an explicit crate
52 /// name optional.
53 pub fn allows_crate_discovery(&self) -> bool {
54 matches!(
55 self,
56 Source::Git { .. } | Source::GitHub { .. } | Source::GitLab { .. } | Source::Path { .. }
57 )
58 }
59}
60
61/// A request to resolve a single crate.
62///
63/// This is the cratespec-layer input to [`CrateSpec::load`].
64#[derive(Clone, Debug, Default)]
65pub struct CrateRequest {
66 /// The crate name, or `None` when it is discoverable from the source (e.g. `--git`/`--path`).
67 pub name: Option<String>,
68
69 /// The requested version requirement as a raw string, parsed into a [`VersionReq`] by
70 /// [`CrateSpec::load`]. `None` selects the latest version (or a config-pinned one).
71 pub version: Option<String>,
72
73 /// Where to obtain the crate.
74 pub source: Source,
75
76 /// Which git ref to use, for git-backed sources.
77 pub git_ref: GitSelector,
78}
79
80impl CrateRequest {
81 /// Build a request for a tool specified in the `cgx` config `[tools]` section, identified by
82 /// its name in that table.
83 pub fn for_configured_tool(name: &str) -> Self {
84 // Only `name` is set; `source`, `version`, and `git_ref` are intentionally left at their
85 // defaults. That is sufficient because [`CrateSpec::load`] will resolve this request with
86 // a default `source` by looking up the crate name in the `[tools]` config section and
87 // applying its configured source and version - exactly the way a bare `cgx <name>`
88 // invocation resolves. Pre-filling those fields here would duplicate that lookup.
89 //
90 // Of course that assumes that the caller is already certain that `name` appears in the
91 // `[tools]` config section, but if that assumption doesn't hold it's a bug in the caller
92 // not here.
93 Self {
94 name: Some(name.to_string()),
95 ..Self::default()
96 }
97 }
98}
99
100/// A specification of a crate that the user wants to execute.
101///
102/// Note that "crate" here doesn't necessarily mean "crate on Crates.io". We support various ways
103/// of referring to a crate to run, which is why this enum type is needed. It abstracts away the
104/// various ways the user might specify a crate to run. Ultimately all of these need to be
105/// resolved to a path in the local filesystem, controlled by cgx, from which we can build and run.
106///
107/// ## Versioning
108///
109/// For crate specs that point to registries (which store multiple versions of a crate), the
110/// default is to choose the latest version. If a version is specified, then the most recent
111/// version that matches the specification is chosen. If no such version exists then an error
112/// occurs.
113///
114/// For crate specs that point to local paths, forges, or git repos, there is no choice of
115/// version; the version of the crate is whatever it is at the specified location. In those cases,
116/// if the `version` field is present, it is validated against the version found at the location,
117/// and if it's not compatible then an error occurs.
118#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
119pub enum CrateSpec {
120 /// A crate on Crates.io, specified by its name and optional version.
121 CratesIo {
122 name: String,
123 version: Option<VersionReq>,
124 },
125
126 /// A crate on some other registry, specified by its name and optional version.
127 Registry {
128 /// The registry source (either a named registry or a direct index URL)
129 source: RegistrySource,
130 name: String,
131 version: Option<VersionReq>,
132 },
133
134 /// A crate in a git repository, specified by the repository URL and optional branch, tag, or
135 /// commit hash.
136 ///
137 /// The `name` field is optional. If omitted, it will be discovered from the repository
138 /// (which must contain exactly one crate). If the repository contains multiple crates,
139 /// the name must be specified.
140 ///
141 /// If the `version` field is present, the crate found at the specified repo must have a
142 /// version that is compatible with the version specification or an error occurs.
143 Git {
144 repo: String,
145 selector: GitSelector,
146 name: Option<String>,
147 version: Option<VersionReq>,
148 },
149
150 /// A crate in a repo in some software Forge, specified by its repo, optional path within that
151 /// repo, and optional branch, tag, or commit hash.
152 ///
153 /// The `name` field is optional. If omitted, it will be discovered from the repository
154 /// (which must contain exactly one crate). If the repository contains multiple crates,
155 /// the name must be specified.
156 Forge {
157 /// A repository within a software forge
158 forge: Forge,
159
160 /// A branch, tag, or commit hash within the repository
161 selector: GitSelector,
162
163 name: Option<String>,
164
165 version: Option<VersionReq>,
166 },
167
168 /// A crate in a local directory, specified by the path to the directory containing the crate's
169 /// `Cargo.toml` or a workspace `Cargo.toml` to which the crate belongs.
170 ///
171 /// The `name` field is optional. If omitted, it will be discovered from the path
172 /// (which must contain exactly one crate). If the path contains multiple crates
173 /// (i.e., a workspace), the name must be specified.
174 LocalDir {
175 path: PathBuf,
176 name: Option<String>,
177 version: Option<VersionReq>,
178 },
179}
180
181impl CrateSpec {
182 /// Resolve a [`CrateRequest`] into a [`CrateSpec`], respecting config-based overrides.
183 ///
184 /// This method applies config-based transformations and overrides:
185 /// 1. Alias resolution: Maps short names to full crate names (e.g., `rg` -> `ripgrep`)
186 /// 2. Tool pinning: Applies version pinning from config for known tools
187 /// 3. Default registry: Uses config's default registry when no registry specified
188 ///
189 /// Priority order for version selection:
190 /// 1. The user's explicitly specified version requirement in [`CrateRequest::version`]
191 /// 2. Config tool pinning
192 /// 3. Current/latest version of the crate at whatever source is being used
193 pub fn load(config: &Config, req: &CrateRequest) -> Result<Self> {
194 // Apply alias resolution from config.
195 let name = req
196 .name
197 .as_ref()
198 .map(|name| config.aliases.get(name).unwrap_or(name));
199
200 // Parse the reconciled CLI version requirement, if any. The `@VERSION` / `--crate-version`
201 // reconciliation already happened during CLI translation; this is the single place a CLI
202 // version string is turned into a `VersionReq`, shared with config tool pinning below.
203 let cli_version = req
204 .version
205 .as_deref()
206 .map(|v| VersionReq::parse(v).with_context(|_| error::InvalidVersionReqSnafu { version: v }))
207 .transpose()?;
208
209 // Apply tool pinning from config if no CLI version specified
210 let version = if cli_version.is_none() {
211 if let Some(tool_name) = name {
212 config
213 .tools
214 .get(tool_name)
215 .and_then(|tool_config| match tool_config {
216 ToolConfig::Version(v)
217 | ToolConfig::Detailed(ToolConfigDetailed { version: Some(v), .. }) => {
218 VersionReq::parse(v).ok()
219 }
220 ToolConfig::Detailed(ToolConfigDetailed { version: None, .. }) => None,
221 })
222 } else {
223 None
224 }
225 } else {
226 cli_version
227 };
228
229 let git_selector = req.git_ref.clone();
230
231 if !matches!(git_selector, GitSelector::DefaultBranch) && !req.source.is_git() {
232 return error::GitSelectorWithoutGitSourceSnafu.fail();
233 }
234
235 // Construct the appropriate CrateSpec variant based on the resolved source.
236 match &req.source {
237 Source::Git { url } => {
238 if let Some(forge) = Forge::try_parse_from_url(url) {
239 Ok(CrateSpec::Forge {
240 forge,
241 selector: git_selector,
242 name: name.cloned(),
243 version,
244 })
245 } else {
246 Ok(CrateSpec::Git {
247 repo: url.clone(),
248 selector: git_selector,
249 name: name.cloned(),
250 version,
251 })
252 }
253 }
254 Source::Registry { name: registry } => {
255 let name = name.context(error::MissingCrateParameterSnafu)?;
256 Ok(CrateSpec::Registry {
257 source: RegistrySource::Named(registry.clone()),
258 name: name.clone(),
259 version,
260 })
261 }
262 Source::Index { url } => {
263 let name = name.context(error::MissingCrateParameterSnafu)?;
264 let index_url = Url::parse(url).with_context(|_| error::InvalidUrlSnafu { url })?;
265 Ok(CrateSpec::Registry {
266 source: RegistrySource::IndexUrl(index_url),
267 name: name.clone(),
268 version,
269 })
270 }
271 Source::Path { path } => Ok(CrateSpec::LocalDir {
272 path: path.clone(),
273 name: name.cloned(),
274 version,
275 }),
276 Source::GitHub { repo, custom_url } => {
277 let (owner, repo) = Self::parse_owner_repo(repo)?;
278 let custom_url = if let Some(url_str) = custom_url {
279 Some(Url::parse(url_str).with_context(|_| error::InvalidUrlSnafu { url: url_str })?)
280 } else {
281 None
282 };
283 Ok(CrateSpec::Forge {
284 forge: Forge::GitHub {
285 custom_url,
286 owner,
287 repo,
288 },
289 selector: git_selector,
290 name: name.cloned(),
291 version,
292 })
293 }
294 Source::GitLab { repo, custom_url } => {
295 let (owner, repo) = Self::parse_owner_repo(repo)?;
296 let custom_url = if let Some(url_str) = custom_url {
297 Some(Url::parse(url_str).with_context(|_| error::InvalidUrlSnafu { url: url_str })?)
298 } else {
299 None
300 };
301 Ok(CrateSpec::Forge {
302 forge: Forge::GitLab {
303 custom_url,
304 owner,
305 repo,
306 },
307 selector: git_selector,
308 name: name.cloned(),
309 version,
310 })
311 }
312 Source::Default => {
313 // No CLI source flag - check tool config, then default_registry, then crates.io.
314
315 // First check if tool config specifies a source
316 if let Some(tool_name) = name {
317 if let Some(tool_config) = config.tools.get(tool_name) {
318 match tool_config {
319 ToolConfig::Detailed(ToolConfigDetailed {
320 git: Some(git_url),
321 branch,
322 tag,
323 rev,
324 ..
325 }) => {
326 // Tool config specifies git source
327 let selector = match (branch.as_ref(), tag.as_ref(), rev.as_ref()) {
328 (Some(b), None, None) => GitSelector::Branch(b.clone()),
329 (None, Some(t), None) => GitSelector::Tag(t.clone()),
330 (None, None, Some(r)) => GitSelector::Commit(r.clone()),
331 _ => GitSelector::DefaultBranch,
332 };
333
334 if let Some(forge) = Forge::try_parse_from_url(git_url) {
335 return Ok(CrateSpec::Forge {
336 forge,
337 selector,
338 name: name.cloned(),
339 version,
340 });
341 } else {
342 return Ok(CrateSpec::Git {
343 repo: git_url.clone(),
344 selector,
345 name: name.cloned(),
346 version,
347 });
348 }
349 }
350 ToolConfig::Detailed(ToolConfigDetailed {
351 registry: Some(reg), ..
352 }) => {
353 // Tool config specifies registry
354 let name = name.context(error::MissingCrateParameterSnafu)?;
355 return Ok(CrateSpec::Registry {
356 source: RegistrySource::Named(reg.clone()),
357 name: name.clone(),
358 version,
359 });
360 }
361 ToolConfig::Detailed(ToolConfigDetailed { path: Some(p), .. }) => {
362 // Tool config specifies local path
363 return Ok(CrateSpec::LocalDir {
364 path: p.clone(),
365 name: name.cloned(),
366 version,
367 });
368 }
369 _ => {
370 // Tool config doesn't specify source - fall through to defaults
371 }
372 }
373 }
374 }
375
376 // At this point all of the configurations in which an explicit crate name is
377 // optional have been eliminated, so we require a crate name.
378 let name = name.context(error::MissingCrateParameterSnafu)?;
379
380 if let Some(ref default_registry) = config.default_registry {
381 // Use config's default registry
382 Ok(CrateSpec::Registry {
383 source: RegistrySource::Named(default_registry.clone()),
384 name: name.clone(),
385 version,
386 })
387 } else {
388 // Use crates.io
389 Ok(CrateSpec::CratesIo {
390 name: name.clone(),
391 version,
392 })
393 }
394 }
395 }
396 }
397
398 /// Get the crate name used for looking up the crate in the `[tools]` TOML section,
399 /// if one is known.
400 ///
401 /// Not all `CrateSpec` variants have a known crate name; for those variants, unfortunately,
402 /// the contents of the `[tools]` section cannot be used to configure them, and this method
403 /// returns `None`.
404 pub fn configured_tool_name(&self) -> Option<&str> {
405 match self {
406 CrateSpec::CratesIo { name, .. }
407 | CrateSpec::Registry { name, .. }
408 | CrateSpec::Git { name: Some(name), .. }
409 | CrateSpec::Forge { name: Some(name), .. }
410 | CrateSpec::LocalDir { name: Some(name), .. } => Some(name.as_str()),
411 CrateSpec::Git { name: None, .. }
412 | CrateSpec::Forge { name: None, .. }
413 | CrateSpec::LocalDir { name: None, .. } => None,
414 }
415 }
416
417 /// Parse owner/repo format used by GitHub and GitLab.
418 fn parse_owner_repo(repo_str: &str) -> Result<(String, String)> {
419 if let Some((owner, repo)) = repo_str.split_once('/') {
420 if owner.is_empty() || repo.is_empty() {
421 return error::InvalidRepoFormatSnafu { repo: repo_str }.fail();
422 }
423 Ok((owner.to_string(), repo.to_string()))
424 } else {
425 error::InvalidRepoFormatSnafu { repo: repo_str }.fail()
426 }
427 }
428}
429
430/// Specifies how to identify a registry source.
431///
432/// Registries can be specified either by a named configuration in `.cargo/config.toml` or by
433/// directly providing the index URL.
434#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
435#[serde(rename_all = "snake_case")]
436pub enum RegistrySource {
437 /// A named registry configured in `.cargo/config.toml` (corresponds to `--registry`).
438 Named(String),
439
440 /// A direct registry index URL (corresponds to `--index`).
441 IndexUrl(Url),
442}
443
444/// Supported software forges where crates can be hosted
445#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
446#[serde(rename_all = "snake_case")]
447pub enum Forge {
448 GitHub {
449 /// Custom URL for Github Enterprise instances; None for github.com
450 custom_url: Option<Url>,
451 owner: String,
452 repo: String,
453 },
454 GitLab {
455 /// Custom URL for self-hosted GitLab instances; None for gitlab.com
456 custom_url: Option<Url>,
457 owner: String,
458 repo: String,
459 },
460}
461
462impl Forge {
463 /// The HTTPS URL to the repository root (no `.git` suffix).
464 ///
465 /// This is the URL that is intended for humans to view to look at the repo in a browser.
466 /// This is also typically what would be placed in the Cargo.toml `repository` field for a
467 /// crate.
468 ///
469 /// Use this for API and release URLs. Use [`Forge::git_url`] when a
470 /// `.git`-suffixed clone URL is needed.
471 pub fn repo_url(&self) -> String {
472 match self {
473 Forge::GitHub {
474 custom_url,
475 owner,
476 repo,
477 }
478 | Forge::GitLab {
479 custom_url,
480 owner,
481 repo,
482 } => {
483 let base = custom_url
484 .as_ref()
485 .map_or(self.default_host(), |u| u.as_str().trim_end_matches('/'));
486 format!("{}/{}/{}", base, owner, repo)
487 }
488 }
489 }
490
491 /// Convert this forge reference into a git URL
492 pub fn git_url(&self) -> String {
493 format!("{}.git", self.repo_url())
494 }
495
496 fn default_host(&self) -> &'static str {
497 match self {
498 Forge::GitHub { .. } => "https://github.com",
499 Forge::GitLab { .. } => "https://gitlab.com",
500 }
501 }
502
503 /// Attempt to parse a URL into a reference to a repo in a forge
504 ///
505 /// When a known forge like Github or Gitlab is used, treating it as a forge as opposed to a
506 /// generic Git URL is important because we can use that forge's API to look for binary
507 /// releases for the crate, which if found will dramatically speed up installation.
508 ///
509 /// Only HTTPS urls are recognized, and only URLs that point to the root of a repository, on
510 /// the forges that we have API support for.
511 pub fn try_parse_from_url(git_url: &str) -> Option<Self> {
512 let url = Url::parse(git_url).ok()?;
513
514 if url.scheme() != "https" {
515 return None;
516 }
517
518 let host = url.host_str()?;
519
520 let path = url.path();
521 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
522
523 if segments.len() != 2 {
524 return None;
525 }
526
527 let owner = segments[0].to_string();
528 let mut repo = segments[1].to_string();
529
530 if repo.ends_with(".git") {
531 #[expect(
532 clippy::string_slice,
533 reason = "guarded by ends_with(\".git\"); the 4 trailing ASCII bytes are a valid char \
534 boundary in range"
535 )]
536 let trimmed = repo[..repo.len() - 4].to_string();
537 repo = trimmed;
538 }
539
540 match host {
541 "github.com" => Some(Forge::GitHub {
542 custom_url: None,
543 owner,
544 repo,
545 }),
546 "gitlab.com" => Some(Forge::GitLab {
547 custom_url: None,
548 owner,
549 repo,
550 }),
551 _other => None,
552 }
553 }
554}
555
556#[cfg(test)]
557mod tests {
558 use assert_matches::assert_matches;
559
560 use super::*;
561 use crate::{
562 cli::Cli,
563 config::{Config, ToolConfig, ToolConfigDetailed},
564 };
565
566 /// Test that config aliases are resolved before processing the crate spec.
567 ///
568 /// Simulated config:
569 /// ```toml
570 /// [aliases]
571 /// rg = "ripgrep"
572 /// ```
573 ///
574 /// Command: `cgx rg`
575 ///
576 /// Expected: Alias `rg` resolves to `ripgrep`, producing a crates.io spec for ripgrep.
577 #[test]
578 fn test_alias_resolution() {
579 let mut config = Config::default();
580 config.aliases.insert("rg".to_string(), "ripgrep".to_string());
581
582 let cli = Cli::parse_from_test_args(["rg"]);
583 let spec = CrateSpec::load(&config, &cli.crate_args().crate_request().unwrap()).unwrap();
584
585 assert_matches!(
586 spec,
587 CrateSpec::CratesIo { ref name, .. } if name == "ripgrep"
588 );
589 }
590
591 /// Test that tools can be pinned to specific versions using simple string syntax.
592 ///
593 /// Simulated config:
594 /// ```toml
595 /// [tools]
596 /// ripgrep = "14.0"
597 /// ```
598 ///
599 /// Command: `cgx ripgrep`
600 ///
601 /// Expected: Uses pinned version 14.0 from config.
602 #[test]
603 fn test_tool_version_pinning_simple() {
604 let mut config = Config::default();
605 config
606 .tools
607 .insert("ripgrep".to_string(), ToolConfig::Version("14.0".to_string()));
608
609 let cli = Cli::parse_from_test_args(["ripgrep"]);
610 let spec = CrateSpec::load(&config, &cli.crate_args().crate_request().unwrap()).unwrap();
611
612 assert_matches!(
613 spec,
614 CrateSpec::CratesIo { ref name, version: Some(ref v) }
615 if name == "ripgrep" && v == &VersionReq::parse("14.0").unwrap()
616 );
617 }
618
619 /// Test that tools can be pinned to specific versions using detailed table syntax.
620 ///
621 /// Simulated config:
622 /// ```toml
623 /// [tools]
624 /// ripgrep = { version = "14.0" }
625 /// ```
626 ///
627 /// Command: `cgx ripgrep`
628 ///
629 /// Expected: Uses pinned version 14.0 from detailed config.
630 #[test]
631 fn test_tool_version_pinning_detailed() {
632 let mut config = Config::default();
633 config.tools.insert(
634 "ripgrep".to_string(),
635 ToolConfig::Detailed(ToolConfigDetailed {
636 default_features: true,
637 version: Some("14.0".to_string()),
638 features: None,
639 registry: None,
640 git: None,
641 branch: None,
642 tag: None,
643 rev: None,
644 path: None,
645 }),
646 );
647
648 let cli = Cli::parse_from_test_args(["ripgrep"]);
649 let spec = CrateSpec::load(&config, &cli.crate_args().crate_request().unwrap()).unwrap();
650
651 assert_matches!(
652 spec,
653 CrateSpec::CratesIo { ref name, version: Some(ref v) }
654 if name == "ripgrep" && v == &VersionReq::parse("14.0").unwrap()
655 );
656 }
657
658 /// Test that tools can specify a custom registry in config.
659 ///
660 /// Simulated config:
661 /// ```toml
662 /// [tools]
663 /// my-tool = { version = "1.0", registry = "my-registry" }
664 /// ```
665 ///
666 /// Command: `cgx my-tool`
667 ///
668 /// Expected: Produces [`CrateSpec::Registry`] with the specified registry name.
669 /// This should behave as if the user had run `cgx my-tool --registry my-registry --version
670 /// 1.0`.
671 #[test]
672 fn test_tool_with_registry() {
673 let mut config = Config::default();
674 config.tools.insert(
675 "my-tool".to_string(),
676 ToolConfig::Detailed(ToolConfigDetailed {
677 default_features: true,
678 version: Some("1.0".to_string()),
679 registry: Some("my-registry".to_string()),
680 features: None,
681 git: None,
682 branch: None,
683 tag: None,
684 rev: None,
685 path: None,
686 }),
687 );
688
689 let cli = Cli::parse_from_test_args(["my-tool"]);
690 let spec = CrateSpec::load(&config, &cli.crate_args().crate_request().unwrap()).unwrap();
691
692 assert_matches!(
693 spec,
694 CrateSpec::Registry {
695 source: RegistrySource::Named(ref reg),
696 ref name,
697 version: Some(ref v)
698 } if reg == "my-registry" && name == "my-tool" && v == &VersionReq::parse("1.0").unwrap()
699 );
700 }
701
702 /// Test that tools can specify a git URL in config.
703 ///
704 /// Simulated config:
705 /// ```toml
706 /// [tools]
707 /// my-tool = { git = "https://example.com/repo.git" }
708 /// ```
709 ///
710 /// Command: `cgx my-tool`
711 ///
712 /// Expected: Produces [`CrateSpec::Git`] with the specified repo URL.
713 /// This should behave as if the user had run `cgx my-tool --git https://example.com/repo.git`.
714 #[test]
715 fn test_tool_with_git_url() {
716 let mut config = Config::default();
717 config.tools.insert(
718 "my-tool".to_string(),
719 ToolConfig::Detailed(ToolConfigDetailed {
720 default_features: true,
721 version: None,
722 git: Some("https://example.com/repo.git".to_string()),
723 branch: None,
724 registry: None,
725 features: None,
726 tag: None,
727 rev: None,
728 path: None,
729 }),
730 );
731
732 let cli = Cli::parse_from_test_args(["my-tool"]);
733 let spec = CrateSpec::load(&config, &cli.crate_args().crate_request().unwrap()).unwrap();
734
735 assert_matches!(
736 spec,
737 CrateSpec::Git {
738 ref repo,
739 selector: GitSelector::DefaultBranch,
740 name: Some(ref n),
741 version: None
742 } if repo == "https://example.com/repo.git" && n == "my-tool"
743 );
744 }
745
746 /// Test that GitHub URLs in config are recognized and produce [`CrateSpec::Forge`] variants.
747 ///
748 /// Simulated config:
749 /// ```toml
750 /// [tools]
751 /// my-tool = { git = "https://github.com/owner/repo.git" }
752 /// ```
753 ///
754 /// Command: `cgx my-tool`
755 ///
756 /// Expected: Produces [`CrateSpec::Forge`] with GitHub forge, enabling potential use of
757 /// GitHub Releases API for binary downloads.
758 #[test]
759 fn test_tool_with_github_url() {
760 let mut config = Config::default();
761 config.tools.insert(
762 "my-tool".to_string(),
763 ToolConfig::Detailed(ToolConfigDetailed {
764 default_features: true,
765 version: None,
766 git: Some("https://github.com/owner/repo.git".to_string()),
767 tag: None,
768 registry: None,
769 features: None,
770 branch: None,
771 rev: None,
772 path: None,
773 }),
774 );
775
776 let cli = Cli::parse_from_test_args(["my-tool"]);
777 let spec = CrateSpec::load(&config, &cli.crate_args().crate_request().unwrap()).unwrap();
778
779 assert_matches!(
780 spec,
781 CrateSpec::Forge {
782 forge: Forge::GitHub { custom_url: None, ref owner, ref repo },
783 selector: GitSelector::DefaultBranch,
784 name: Some(ref n),
785 version: None
786 } if owner == "owner" && repo == "repo" && n == "my-tool"
787 );
788 }
789
790 /// Test that GitLab URLs in config are recognized and produce [`CrateSpec::Forge`] variants.
791 ///
792 /// Simulated config:
793 /// ```toml
794 /// [tools]
795 /// my-tool = { git = "https://gitlab.com/owner/repo.git" }
796 /// ```
797 ///
798 /// Command: `cgx my-tool`
799 ///
800 /// Expected: Produces [`CrateSpec::Forge`] with GitLab forge, enabling potential use of
801 /// GitLab Releases API for binary downloads.
802 #[test]
803 fn test_tool_with_gitlab_url() {
804 let mut config = Config::default();
805 config.tools.insert(
806 "my-tool".to_string(),
807 ToolConfig::Detailed(ToolConfigDetailed {
808 default_features: true,
809 version: None,
810 git: Some("https://gitlab.com/owner/repo.git".to_string()),
811 tag: None,
812 registry: None,
813 features: None,
814 branch: None,
815 rev: None,
816 path: None,
817 }),
818 );
819
820 let cli = Cli::parse_from_test_args(["my-tool"]);
821 let spec = CrateSpec::load(&config, &cli.crate_args().crate_request().unwrap()).unwrap();
822
823 assert_matches!(
824 spec,
825 CrateSpec::Forge {
826 forge: Forge::GitLab { custom_url: None, ref owner, ref repo },
827 selector: GitSelector::DefaultBranch,
828 name: Some(ref n),
829 version: None
830 } if owner == "owner" && repo == "repo" && n == "my-tool"
831 );
832 }
833
834 /// Test that tools can specify a local filesystem path in config.
835 ///
836 /// Simulated config:
837 /// ```toml
838 /// [tools]
839 /// my-tool = { path = "/some/path" }
840 /// ```
841 ///
842 /// Command: `cgx my-tool`
843 ///
844 /// Expected: Produces [`CrateSpec::LocalDir`] with the specified path.
845 /// This should behave as if the user had run `cgx my-tool --path /some/path`.
846 #[test]
847 fn test_tool_with_path() {
848 let mut config = Config::default();
849 config.tools.insert(
850 "my-tool".to_string(),
851 ToolConfig::Detailed(ToolConfigDetailed {
852 default_features: true,
853 version: None,
854 path: Some(PathBuf::from("/some/path")),
855 registry: None,
856 features: None,
857 git: None,
858 branch: None,
859 tag: None,
860 rev: None,
861 }),
862 );
863
864 let cli = Cli::parse_from_test_args(["my-tool"]);
865 let spec = CrateSpec::load(&config, &cli.crate_args().crate_request().unwrap()).unwrap();
866
867 assert_matches!(
868 spec,
869 CrateSpec::LocalDir {
870 ref path,
871 name: Some(ref n),
872 version: None
873 } if path == &PathBuf::from("/some/path") && n == "my-tool"
874 );
875 }
876
877 /// Test that tools can specify git + branch selector in config.
878 ///
879 /// Simulated config:
880 /// ```toml
881 /// [tools]
882 /// my-tool = { git = "https://example.com/repo.git", branch = "develop" }
883 /// ```
884 ///
885 /// Command: `cgx my-tool`
886 ///
887 /// Expected: Produces [`CrateSpec::Git`] with [`GitSelector::Branch`].
888 /// Equivalent to: `cgx my-tool --git https://example.com/repo.git --branch develop`.
889 #[test]
890 fn test_tool_with_git_and_branch() {
891 let mut config = Config::default();
892 config.tools.insert(
893 "my-tool".to_string(),
894 ToolConfig::Detailed(ToolConfigDetailed {
895 default_features: true,
896 version: None,
897 git: Some("https://example.com/repo.git".to_string()),
898 branch: Some("develop".to_string()),
899 registry: None,
900 features: None,
901 tag: None,
902 rev: None,
903 path: None,
904 }),
905 );
906
907 let cli = Cli::parse_from_test_args(["my-tool"]);
908 let spec = CrateSpec::load(&config, &cli.crate_args().crate_request().unwrap()).unwrap();
909
910 assert_matches!(
911 spec,
912 CrateSpec::Git {
913 ref repo,
914 selector: GitSelector::Branch(ref b),
915 name: Some(ref n),
916 version: None
917 } if repo == "https://example.com/repo.git" && b == "develop" && n == "my-tool"
918 );
919 }
920
921 /// Test that tools can specify git + tag selector in config.
922 ///
923 /// Simulated config:
924 /// ```toml
925 /// [tools]
926 /// my-tool = { git = "https://example.com/repo.git", tag = "v1.0.0" }
927 /// ```
928 ///
929 /// Command: `cgx my-tool`
930 ///
931 /// Expected: Produces [`CrateSpec::Git`] with [`GitSelector::Tag`].
932 /// Equivalent to: `cgx my-tool --git https://example.com/repo.git --tag v1.0.0`.
933 #[test]
934 fn test_tool_with_git_and_tag() {
935 let mut config = Config::default();
936 config.tools.insert(
937 "my-tool".to_string(),
938 ToolConfig::Detailed(ToolConfigDetailed {
939 default_features: true,
940 version: None,
941 git: Some("https://example.com/repo.git".to_string()),
942 tag: Some("v1.0.0".to_string()),
943 registry: None,
944 features: None,
945 branch: None,
946 rev: None,
947 path: None,
948 }),
949 );
950
951 let cli = Cli::parse_from_test_args(["my-tool"]);
952 let spec = CrateSpec::load(&config, &cli.crate_args().crate_request().unwrap()).unwrap();
953
954 assert_matches!(
955 spec,
956 CrateSpec::Git {
957 ref repo,
958 selector: GitSelector::Tag(ref t),
959 name: Some(ref n),
960 version: None
961 } if repo == "https://example.com/repo.git" && t == "v1.0.0" && n == "my-tool"
962 );
963 }
964
965 /// Test that tools can specify git + rev (commit) selector in config.
966 ///
967 /// Simulated config:
968 /// ```toml
969 /// [tools]
970 /// my-tool = { git = "https://example.com/repo.git", rev = "abc123" }
971 /// ```
972 ///
973 /// Command: `cgx my-tool`
974 ///
975 /// Expected: Produces [`CrateSpec::Git`] with [`GitSelector::Commit`].
976 /// Equivalent to: `cgx my-tool --git https://example.com/repo.git --rev abc123`.
977 #[test]
978 fn test_tool_with_git_and_rev() {
979 let mut config = Config::default();
980 config.tools.insert(
981 "my-tool".to_string(),
982 ToolConfig::Detailed(ToolConfigDetailed {
983 default_features: true,
984 version: None,
985 git: Some("https://example.com/repo.git".to_string()),
986 rev: Some("abc123".to_string()),
987 registry: None,
988 features: None,
989 branch: None,
990 tag: None,
991 path: None,
992 }),
993 );
994
995 let cli = Cli::parse_from_test_args(["my-tool"]);
996 let spec = CrateSpec::load(&config, &cli.crate_args().crate_request().unwrap()).unwrap();
997
998 assert_matches!(
999 spec,
1000 CrateSpec::Git {
1001 ref repo,
1002 selector: GitSelector::Commit(ref c),
1003 name: Some(ref n),
1004 version: None
1005 } if repo == "https://example.com/repo.git" && c == "abc123" && n == "my-tool"
1006 );
1007 }
1008
1009 /// Test that CLI `--version` flag takes precedence over config tool version.
1010 ///
1011 /// Simulated config:
1012 /// ```toml
1013 /// [tools]
1014 /// ripgrep = "14.0"
1015 /// ```
1016 ///
1017 /// Command: `cgx ripgrep --version 13.0`
1018 ///
1019 /// Expected: Uses version 13.0 from CLI, not 14.0 from config.
1020 #[test]
1021 fn test_cli_version_flag_overrides_config() {
1022 let mut config = Config::default();
1023 config
1024 .tools
1025 .insert("ripgrep".to_string(), ToolConfig::Version("14.0".to_string()));
1026
1027 let cli = Cli::parse_from_test_args(["--crate-version", "13.0", "ripgrep"]);
1028 let spec = CrateSpec::load(&config, &cli.crate_args().crate_request().unwrap()).unwrap();
1029
1030 assert_matches!(
1031 spec,
1032 CrateSpec::CratesIo { ref name, version: Some(ref v) }
1033 if name == "ripgrep" && v == &VersionReq::parse("13.0").unwrap()
1034 );
1035 }
1036
1037 /// Test that CLI `@version` syntax takes precedence over config tool version.
1038 ///
1039 /// Simulated config:
1040 /// ```toml
1041 /// [tools]
1042 /// ripgrep = "14.0"
1043 /// ```
1044 ///
1045 /// Command: `cgx ripgrep@13.0`
1046 ///
1047 /// Expected: Uses version 13.0 from CLI, not 14.0 from config.
1048 #[test]
1049 fn test_cli_at_version_overrides_config() {
1050 let mut config = Config::default();
1051 config
1052 .tools
1053 .insert("ripgrep".to_string(), ToolConfig::Version("14.0".to_string()));
1054
1055 let cli = Cli::parse_from_test_args(["ripgrep@13.0"]);
1056 let spec = CrateSpec::load(&config, &cli.crate_args().crate_request().unwrap()).unwrap();
1057
1058 assert_matches!(
1059 spec,
1060 CrateSpec::CratesIo { ref name, version: Some(ref v) }
1061 if name == "ripgrep" && v == &VersionReq::parse("13.0").unwrap()
1062 );
1063 }
1064
1065 /// Test that CLI `--registry` flag takes precedence over config git source.
1066 ///
1067 /// Simulated config:
1068 /// ```toml
1069 /// [tools]
1070 /// my-tool = { version = "1.0", git = "https://github.com/owner/repo.git" }
1071 /// ```
1072 ///
1073 /// Command: `cgx my-tool --registry other-registry`
1074 ///
1075 /// Expected: Uses registry from CLI, ignoring git source from config.
1076 /// Version 1.0 from config is preserved.
1077 #[test]
1078 fn test_cli_registry_overrides_config_git() {
1079 let mut config = Config::default();
1080 config.tools.insert(
1081 "my-tool".to_string(),
1082 ToolConfig::Detailed(ToolConfigDetailed {
1083 default_features: true,
1084 version: Some("1.0".to_string()),
1085 git: Some("https://github.com/owner/repo.git".to_string()),
1086 registry: None,
1087 features: None,
1088 branch: None,
1089 tag: None,
1090 rev: None,
1091 path: None,
1092 }),
1093 );
1094
1095 let cli = Cli::parse_from_test_args(["--registry", "other-registry", "my-tool"]);
1096 let spec = CrateSpec::load(&config, &cli.crate_args().crate_request().unwrap()).unwrap();
1097
1098 assert_matches!(
1099 spec,
1100 CrateSpec::Registry {
1101 source: RegistrySource::Named(ref reg),
1102 ref name,
1103 version: Some(ref v)
1104 } if reg == "other-registry"
1105 && name == "my-tool"
1106 && v == &VersionReq::parse("1.0").unwrap()
1107 );
1108 }
1109
1110 /// Test that CLI `--git` flag takes precedence over config registry source.
1111 ///
1112 /// Simulated config:
1113 /// ```toml
1114 /// [tools]
1115 /// my-tool = { version = "1.0", registry = "my-registry" }
1116 /// ```
1117 ///
1118 /// Command: `cgx my-tool --git https://example.com/repo.git`
1119 ///
1120 /// Expected: Uses git from CLI, ignoring registry from config.
1121 /// Version 1.0 from config is preserved.
1122 #[test]
1123 fn test_cli_git_overrides_config_registry() {
1124 let mut config = Config::default();
1125 config.tools.insert(
1126 "my-tool".to_string(),
1127 ToolConfig::Detailed(ToolConfigDetailed {
1128 default_features: true,
1129 version: Some("1.0".to_string()),
1130 registry: Some("my-registry".to_string()),
1131 git: None,
1132 features: None,
1133 branch: None,
1134 tag: None,
1135 rev: None,
1136 path: None,
1137 }),
1138 );
1139
1140 let cli = Cli::parse_from_test_args(["--git", "https://example.com/repo.git", "my-tool"]);
1141 let spec = CrateSpec::load(&config, &cli.crate_args().crate_request().unwrap()).unwrap();
1142
1143 assert_matches!(
1144 spec,
1145 CrateSpec::Git {
1146 ref repo,
1147 selector: GitSelector::DefaultBranch,
1148 name: Some(ref n),
1149 version: Some(ref v)
1150 } if repo == "https://example.com/repo.git"
1151 && n == "my-tool"
1152 && v == &VersionReq::parse("1.0").unwrap()
1153 );
1154 }
1155
1156 /// Test that alias resolution happens first, then tool config is applied.
1157 ///
1158 /// Simulated config:
1159 /// ```toml
1160 /// [aliases]
1161 /// rg = "ripgrep"
1162 ///
1163 /// [tools]
1164 /// ripgrep = "14.0"
1165 /// ```
1166 ///
1167 /// Command: `cgx rg`
1168 ///
1169 /// Expected: Alias `rg` resolves to `ripgrep`, then tool config for `ripgrep` applies,
1170 /// resulting in version 14.0 from crates.io.
1171 #[test]
1172 fn test_alias_with_tool_config() {
1173 let mut config = Config::default();
1174 config.aliases.insert("rg".to_string(), "ripgrep".to_string());
1175 config
1176 .tools
1177 .insert("ripgrep".to_string(), ToolConfig::Version("14.0".to_string()));
1178
1179 let cli = Cli::parse_from_test_args(["rg"]);
1180 let spec = CrateSpec::load(&config, &cli.crate_args().crate_request().unwrap()).unwrap();
1181
1182 assert_matches!(
1183 spec,
1184 CrateSpec::CratesIo { ref name, version: Some(ref v) }
1185 if name == "ripgrep" && v == &VersionReq::parse("14.0").unwrap()
1186 );
1187 }
1188
1189 /// Test that a tool with only version uses the [`Config::default_registry`] if one is
1190 /// configured.
1191 ///
1192 /// Simulated config:
1193 /// ```toml
1194 /// default_registry = "my-default-registry"
1195 ///
1196 /// [tools]
1197 /// my-tool = "1.0"
1198 /// ```
1199 ///
1200 /// Command: `cgx my-tool`
1201 ///
1202 /// Expected: Since no explicit source is specified in the tool config, uses the
1203 /// [`Config::default_registry`] instead of crates.io.
1204 #[test]
1205 fn test_default_registry_with_simple_tool() {
1206 let config = Config {
1207 default_registry: Some("my-default-registry".to_string()),
1208 tools: [("my-tool".to_string(), ToolConfig::Version("1.0".to_string()))]
1209 .into_iter()
1210 .collect(),
1211 ..Default::default()
1212 };
1213
1214 let cli = Cli::parse_from_test_args(["my-tool"]);
1215 let spec = CrateSpec::load(&config, &cli.crate_args().crate_request().unwrap()).unwrap();
1216
1217 assert_matches!(
1218 spec,
1219 CrateSpec::Registry {
1220 source: RegistrySource::Named(ref reg),
1221 ref name,
1222 version: Some(ref v)
1223 } if reg == "my-default-registry"
1224 && name == "my-tool"
1225 && v == &VersionReq::parse("1.0").unwrap()
1226 );
1227 }
1228
1229 /// Test that tool-specific registry takes precedence over [`Config::default_registry`].
1230 ///
1231 /// Simulated config:
1232 /// ```toml
1233 /// default_registry = "default-registry"
1234 ///
1235 /// [tools]
1236 /// my-tool = { version = "1.0", registry = "tool-registry" }
1237 /// ```
1238 ///
1239 /// Command: `cgx my-tool`
1240 ///
1241 /// Expected: Uses `tool-registry` from tool config, not `default-registry`.
1242 #[test]
1243 fn test_tool_registry_overrides_default_registry() {
1244 let config = Config {
1245 default_registry: Some("default-registry".to_string()),
1246 tools: [(
1247 "my-tool".to_string(),
1248 ToolConfig::Detailed(ToolConfigDetailed {
1249 default_features: true,
1250 version: Some("1.0".to_string()),
1251 registry: Some("tool-registry".to_string()),
1252 features: None,
1253 git: None,
1254 branch: None,
1255 tag: None,
1256 rev: None,
1257 path: None,
1258 }),
1259 )]
1260 .into_iter()
1261 .collect(),
1262 ..Default::default()
1263 };
1264
1265 let cli = Cli::parse_from_test_args(["my-tool"]);
1266 let spec = CrateSpec::load(&config, &cli.crate_args().crate_request().unwrap()).unwrap();
1267
1268 assert_matches!(
1269 spec,
1270 CrateSpec::Registry {
1271 source: RegistrySource::Named(ref reg),
1272 ref name,
1273 version: Some(ref v)
1274 } if reg == "tool-registry"
1275 && name == "my-tool"
1276 && v == &VersionReq::parse("1.0").unwrap()
1277 );
1278 }
1279
1280 #[test]
1281 fn test_repo_url_github_default() {
1282 let forge = Forge::GitHub {
1283 custom_url: None,
1284 owner: "octocat".to_string(),
1285 repo: "hello".to_string(),
1286 };
1287 assert_eq!(forge.repo_url(), "https://github.com/octocat/hello");
1288 }
1289
1290 #[test]
1291 fn test_repo_url_gitlab_default() {
1292 let forge = Forge::GitLab {
1293 custom_url: None,
1294 owner: "acme".to_string(),
1295 repo: "widgets".to_string(),
1296 };
1297 assert_eq!(forge.repo_url(), "https://gitlab.com/acme/widgets");
1298 }
1299
1300 #[test]
1301 fn test_repo_url_github_custom_url() {
1302 let forge = Forge::GitHub {
1303 custom_url: Some(Url::parse("https://github.example.com/").unwrap()),
1304 owner: "octocat".to_string(),
1305 repo: "hello".to_string(),
1306 };
1307 assert_eq!(forge.repo_url(), "https://github.example.com/octocat/hello");
1308 }
1309
1310 #[test]
1311 fn test_repo_url_gitlab_custom_url() {
1312 let forge = Forge::GitLab {
1313 custom_url: Some(Url::parse("https://gitlab.example.com/").unwrap()),
1314 owner: "acme".to_string(),
1315 repo: "widgets".to_string(),
1316 };
1317 assert_eq!(forge.repo_url(), "https://gitlab.example.com/acme/widgets");
1318 }
1319
1320 #[test]
1321 fn test_git_url_is_repo_url_plus_dot_git() {
1322 let forge = Forge::GitHub {
1323 custom_url: None,
1324 owner: "octocat".to_string(),
1325 repo: "hello".to_string(),
1326 };
1327 assert_eq!(forge.git_url(), format!("{}.git", forge.repo_url()));
1328 }
1329
1330 /// Test that features-only config doesn't change the [`CrateSpec`] variant.
1331 ///
1332 /// Simulated config:
1333 /// ```toml
1334 /// [tools]
1335 /// my-tool = { features = ["feat1", "feat2"] }
1336 /// ```
1337 ///
1338 /// Command: `cgx my-tool`
1339 ///
1340 /// Expected: Produces [`CrateSpec::CratesIo`] (the default).
1341 /// Features affect [`crate::builder::BuildOptions`], not [`CrateSpec`].
1342 #[test]
1343 fn test_tool_with_only_features() {
1344 let mut config = Config::default();
1345 config.tools.insert(
1346 "my-tool".to_string(),
1347 ToolConfig::Detailed(ToolConfigDetailed {
1348 default_features: true,
1349 version: None,
1350 features: Some(vec!["feat1".to_string(), "feat2".to_string()]),
1351 registry: None,
1352 git: None,
1353 branch: None,
1354 tag: None,
1355 rev: None,
1356 path: None,
1357 }),
1358 );
1359
1360 let cli = Cli::parse_from_test_args(["my-tool"]);
1361 let spec = CrateSpec::load(&config, &cli.crate_args().crate_request().unwrap()).unwrap();
1362
1363 assert_matches!(
1364 spec,
1365 CrateSpec::CratesIo { ref name, version: None }
1366 if name == "my-tool"
1367 );
1368 }
1369}