Skip to main content

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}