Skip to main content

foundry_cli/
install.rs

1//! Dependency installation shared by Forge commands.
2
3use crate::{
4    lockfile::{DepIdentifier, DepMap, FOUNDRY_LOCK, Lockfile},
5    opts::Dependency,
6    utils::{Git, LoadConfig},
7};
8use clap::{Parser, ValueHint};
9use eyre::{Context, Result};
10use foundry_common::fs;
11use foundry_config::{Config, impl_figment_convert_basic};
12use regex::Regex;
13use semver::Version;
14use soldeer_commands::{Command, Verbosity, commands::install::Install};
15use std::{
16    io::{ErrorKind, IsTerminal},
17    path::{Path, PathBuf},
18    str,
19    sync::LazyLock,
20};
21use yansi::Paint;
22
23static DEPENDENCY_VERSION_TAG_REGEX: LazyLock<Regex> =
24    LazyLock::new(|| Regex::new(r"^v?\d+(\.\d+)*$").unwrap());
25
26/// CLI arguments for `forge install`.
27#[derive(Clone, Debug, Parser)]
28#[command(override_usage = "forge install [OPTIONS] [DEPENDENCIES]...
29    forge install [OPTIONS] <github username>/<github project>@<tag>...
30    forge install [OPTIONS] <alias>=<github username>/<github project>@<tag>...
31    forge install [OPTIONS] <https://<github token>@git url>...)]
32    forge install [OPTIONS] <https:// git url>...")]
33pub struct InstallArgs {
34    /// The dependencies to install.
35    ///
36    /// A dependency can be a raw URL, or the path to a GitHub repository.
37    ///
38    /// Additionally, a ref can be provided by adding @ to the dependency path.
39    ///
40    /// A ref can be:
41    /// - A branch: master
42    /// - A tag: v1.2.3
43    /// - A commit: 8e8128
44    ///
45    /// For exact match, a ref can be provided with `@tag=`, `@branch=` or `@rev=` prefix.
46    ///
47    /// Target installation directory can be added via `<alias>=` suffix.
48    /// The dependency will installed to `lib/<alias>`.
49    dependencies: Vec<Dependency>,
50
51    /// The project's root path.
52    ///
53    /// By default root of the Git repository, if in one,
54    /// or the current working directory.
55    #[arg(long, value_hint = ValueHint::DirPath, value_name = "PATH")]
56    pub root: Option<PathBuf>,
57
58    /// Do not create a commit after installing.
59    ///
60    /// This is a noop flag kept for backwards compatibility, as `forge install` no longer commits
61    /// by default. Use `--commit` to opt into creating a commit.
62    #[arg(long, hide = true)]
63    pub no_commit: bool,
64
65    #[command(flatten)]
66    opts: DependencyInstallOpts,
67}
68
69impl_figment_convert_basic!(InstallArgs);
70
71impl InstallArgs {
72    pub async fn run(mut self) -> Result<()> {
73        if self.root.is_none() {
74            self.root = std::env::current_dir()?
75                .ancestors()
76                .find(|root| root.join(Config::FILE_NAME).is_file())
77                .map(Path::to_path_buf);
78        }
79        let mut config = self.load_config()?;
80        self.opts.install(&mut config, self.dependencies).await
81    }
82}
83
84#[derive(Clone, Copy, Debug, Default, Parser)]
85pub struct DependencyInstallOpts {
86    /// Perform shallow clones instead of deep ones.
87    ///
88    /// Improves performance and reduces disk usage, but prevents switching branches or tags.
89    #[arg(long)]
90    pub shallow: bool,
91
92    /// Install without adding the dependency as a submodule.
93    #[arg(long)]
94    pub no_git: bool,
95
96    /// Create a commit after installing the dependencies.
97    #[arg(long)]
98    pub commit: bool,
99}
100
101impl DependencyInstallOpts {
102    pub fn git(self, config: &Config) -> Git<'_> {
103        Git::from_config(config).shallow(self.shallow)
104    }
105
106    /// Installs all missing dependencies.
107    ///
108    /// See also [`Self::install`].
109    ///
110    /// Returns true if any dependency was installed.
111    pub fn install_missing_dependencies(self, config: &mut Config) -> bool {
112        let lib = config.install_lib_dir();
113        if self.git(config).has_missing_dependencies(Some(lib)).unwrap_or(false) {
114            let _ = sh_status!("Missing dependencies found. Installing now...");
115
116            if self.install_existing_dependencies(config).is_err() {
117                let _ =
118                    sh_warn!("Your project has missing dependencies that could not be installed.");
119            }
120            true
121        } else {
122            false
123        }
124    }
125
126    /// Restores existing dependencies without running asynchronous package installation.
127    fn install_existing_dependencies(self, config: &mut Config) -> Result<()> {
128        let git = self.git(config);
129        let install_lib_dir = config.install_lib_dir();
130        let libs = git.root.join(install_lib_dir);
131        let (lockfile, out_of_sync_deps) = self.sync_lockfile(config, &git)?;
132
133        if !self.no_git {
134            // Use the root of the git repository to look for submodules.
135            let root = Git::root_of(git.root)?;
136            match git.has_submodules(Some(&root)) {
137                Ok(true) => {
138                    sh_status!("Updating dependencies in {}", libs.display())?;
139
140                    // recursively fetch all submodules (without fetching latest)
141                    git.submodule_update(false, false, false, true, Some(&libs))?;
142
143                    // checkout submodules at the revs recorded in `foundry.lock`
144                    if let Some(out_of_sync) = &out_of_sync_deps {
145                        for (rel_path, dep_id) in out_of_sync {
146                            git.checkout_at(dep_id.checkout_id(), &git.root.join(rel_path))?;
147                        }
148                    }
149
150                    lockfile.write()?;
151                }
152                Err(err) => {
153                    sh_err!("Failed to check for submodules: {err}")?;
154                }
155                _ => {
156                    // no submodules, nothing to do
157                }
158            }
159        }
160
161        fs::create_dir_all(&libs)?;
162
163        // update `libs` in config if not included yet
164        if !config.libs.iter().any(|p| p == install_lib_dir) {
165            config.libs.push(install_lib_dir.to_path_buf());
166            config.update_libs()?;
167        }
168
169        Ok(())
170    }
171
172    fn sync_lockfile<'a>(
173        self,
174        config: &Config,
175        git: &'a Git<'_>,
176    ) -> Result<(Lockfile<'a>, Option<DepMap>)> {
177        let libs = git.root.join(config.install_lib_dir());
178        let mut lockfile = Lockfile::new(&config.root);
179        if !self.no_git {
180            lockfile = lockfile.with_git(git);
181
182            // Check if submodules are uninitialized, if so, we need to fetch all submodules
183            // This is to ensure that foundry.lock syncs successfully and doesn't error out, when
184            // looking for commits/tags in submodules
185            if git.submodules_uninitialized()? {
186                trace!(lib = %libs.display(), "submodules uninitialized");
187                git.submodule_update(false, false, false, true, Some(&libs))?;
188            }
189        }
190
191        let out_of_sync_deps = lockfile.sync(config.install_lib_dir())?;
192
193        Ok((lockfile, out_of_sync_deps))
194    }
195
196    /// Installs all dependencies
197    pub async fn install(self, config: &mut Config, dependencies: Vec<Dependency>) -> Result<()> {
198        if dependencies.is_empty() {
199            return self.install_existing_dependencies(config);
200        }
201
202        let Self { no_git, commit, .. } = self;
203
204        let git = self.git(config);
205
206        let install_lib_dir = config.install_lib_dir();
207        let libs = git.root.join(install_lib_dir);
208
209        let (mut lockfile, out_of_sync_deps) = self.sync_lockfile(config, &git)?;
210
211        fs::create_dir_all(&libs)?;
212
213        let installer = Installer { git, commit };
214        for dep in dependencies {
215            if dep
216                .name()
217                .split(['/', '\\'])
218                .any(|component| component.is_empty() || matches!(component, "." | ".."))
219            {
220                eyre::bail!("invalid dependency name: {}", dep.name());
221            }
222            let path = libs.join(dep.name());
223            let rel_path = path
224                .strip_prefix(git.root)
225                .wrap_err("Library directory is not relative to the repository root")?;
226            sh_status!(
227                "Installing {} in {} (url: {}, tag: {})",
228                dep.name,
229                path.display(),
230                dep.url.as_deref().unwrap_or("None"),
231                dep.tag.as_deref().unwrap_or("None")
232            )?;
233
234            // this tracks the actual installed tag
235            let installed_tag;
236            let mut dep_id = None;
237            if no_git {
238                installed_tag = installer.install_as_folder(&dep, &path)?;
239            } else {
240                if commit {
241                    git.ensure_clean()?;
242                }
243                installed_tag = installer.install_as_submodule(&dep, &path)?;
244
245                let mut new_insertion = false;
246                // Pin branch to submodule if branch is used
247                if let Some(tag_or_branch) = &installed_tag {
248                    // First, check if this tag has a branch
249                    dep_id = Some(DepIdentifier::resolve_type(&git, &path, tag_or_branch)?);
250                    if git.has_branch(tag_or_branch, &path)?
251                        && dep_id.as_ref().is_some_and(|id| id.is_branch())
252                    {
253                        // always work with relative paths when directly modifying submodules
254                        git.set_submodule_branch(rel_path, tag_or_branch)?;
255                        let root = Git::root_of(git.root)?;
256                        git.root(&root).add_literal(Path::new(".gitmodules"))?;
257
258                        let rev = git.get_rev(tag_or_branch, &path)?;
259
260                        dep_id = Some(DepIdentifier::Branch {
261                            name: tag_or_branch.clone(),
262                            rev,
263                            r#override: false,
264                        });
265                    }
266
267                    trace!(?dep_id, ?tag_or_branch, "resolved dep id");
268                    if let Some(dep_id) = &dep_id {
269                        new_insertion = true;
270                        lockfile.insert(rel_path.to_path_buf(), dep_id.clone());
271                    }
272
273                    if commit {
274                        // update .gitmodules which is at the root of the repo,
275                        // not necessarily at the root of the current Foundry project
276                        let root = Git::root_of(git.root)?;
277                        git.root(&root).add(Some(".gitmodules"))?;
278                    }
279                }
280
281                if new_insertion
282                    || out_of_sync_deps.as_ref().is_some_and(|o| !o.is_empty())
283                    || !lockfile.exists()
284                {
285                    lockfile.write()?;
286                }
287
288                // commit the installation
289                if commit {
290                    let mut msg = String::with_capacity(128);
291                    msg.push_str("forge install: ");
292                    msg.push_str(dep.name());
293
294                    if let Some(tag) = &installed_tag {
295                        msg.push_str("\n\n");
296
297                        if let Some(dep_id) = &dep_id {
298                            msg.push_str(&dep_id.to_string());
299                        } else {
300                            msg.push_str(tag);
301                        }
302                    }
303
304                    if !lockfile.is_empty() {
305                        git.root(&config.root).add(Some(FOUNDRY_LOCK))?;
306                    }
307                    git.commit(&msg)?;
308                }
309            }
310
311            let mut msg = format!("    {} {}", "Installed".green(), dep.name);
312            if let Some(tag) = dep.tag.or(installed_tag) {
313                msg.push(' ');
314
315                if let Some(dep_id) = dep_id {
316                    msg.push_str(&dep_id.to_string());
317                } else {
318                    msg.push_str(tag.as_str());
319                }
320            }
321            sh_status!("{msg}")?;
322
323            // Check if the dependency has soldeer.lock and install soldeer dependencies
324            install_soldeer_deps_if_needed(&path).await.map_err(|e| {
325                eyre::eyre!(
326                    "Failed to install soldeer dependencies for {}: {e}\n\
327                     Run `forge soldeer install` in {} to retry.",
328                    dep.name,
329                    path.display()
330                )
331            })?;
332        }
333
334        // update `libs` in config if not included yet
335        if !config.libs.iter().any(|p| p == install_lib_dir) {
336            config.libs.push(install_lib_dir.to_path_buf());
337            config.update_libs()?;
338        }
339
340        Ok(())
341    }
342}
343
344/// Installs missing dependencies and reloads config only to discover new remappings.
345pub fn install_missing_dependencies<E>(
346    config: &mut Config,
347    reload: impl FnOnce() -> Result<Config, E>,
348) -> Result<(), E> {
349    if DependencyInstallOpts::default().install_missing_dependencies(config)
350        && config.auto_detect_remappings
351    {
352        *config = reload()?;
353    }
354    Ok(())
355}
356
357/// Checks if a dependency has soldeer.lock and installs soldeer dependencies if needed.
358async fn install_soldeer_deps_if_needed(dep_path: &Path) -> Result<()> {
359    let soldeer_lock = dep_path.join("soldeer.lock");
360
361    if soldeer_lock.exists() {
362        sh_status!("    Found soldeer.lock, installing soldeer dependencies...")?;
363
364        // Change to the dependency directory and run soldeer install
365        let original_dir = std::env::current_dir()?;
366        std::env::set_current_dir(dep_path)?;
367
368        let result = soldeer_commands::run(
369            Command::Install(Install::default()),
370            Verbosity::new(
371                foundry_common::shell::verbosity(),
372                if foundry_common::shell::is_quiet() { 1 } else { 0 },
373            ),
374        )
375        .await;
376
377        // Change back to original directory
378        std::env::set_current_dir(original_dir)?;
379
380        result.map_err(|e| eyre::eyre!("Failed to run soldeer install: {e}"))?;
381        sh_status!("    Soldeer dependencies installed successfully")?;
382    }
383
384    Ok(())
385}
386
387#[derive(Clone, Copy, Debug)]
388struct Installer<'a> {
389    git: Git<'a>,
390    commit: bool,
391}
392
393struct NewSubmoduleGuard {
394    root: PathBuf,
395    relative_path: PathBuf,
396    path: PathBuf,
397    module_dir: PathBuf,
398    gitmodules_contents: Option<Vec<u8>>,
399    armed: bool,
400}
401
402impl NewSubmoduleGuard {
403    const fn disarm(&mut self) {
404        self.armed = false;
405    }
406
407    fn rollback(&self) {
408        let git = Git::new(&self.root);
409        if let Err(err) = git.remove_index_path(&self.relative_path) {
410            warn!(%err, "failed to remove submodule after installation failure");
411        }
412        if self.path.exists()
413            && let Err(err) = fs::remove_dir_all(&self.path)
414        {
415            warn!(%err, "failed to remove dependency after installation failure");
416        }
417        if self.module_dir.exists()
418            && let Err(err) = fs::remove_dir_all(&self.module_dir)
419        {
420            warn!(%err, "failed to remove submodule Git directory after installation failure");
421        }
422        if let Err(err) = git.remove_submodule_config(&self.relative_path) {
423            warn!(%err, "failed to remove submodule config after installation failure");
424        }
425        restore_file(&self.root.join(".gitmodules"), self.gitmodules_contents.as_deref());
426        if let Err(err) = git.add_literal(Path::new(".gitmodules")) {
427            warn!(%err, "failed to restore staged .gitmodules after installation failure");
428        }
429    }
430}
431
432impl Drop for NewSubmoduleGuard {
433    fn drop(&mut self) {
434        if self.armed {
435            self.rollback();
436        }
437    }
438}
439
440/// Removes a newly created dependency directory if its installation fails.
441struct NewFolderGuard<'a> {
442    path: &'a Path,
443    armed: bool,
444}
445
446impl<'a> NewFolderGuard<'a> {
447    fn new(path: &'a Path) -> Result<Self> {
448        if let Some(parent) = path.parent() {
449            fs::create_dir_all(parent)?;
450        }
451        // Claim the destination before cloning so rollback cannot remove an existing path.
452        fs::create_dir(path)?;
453        Ok(Self { path, armed: true })
454    }
455
456    const fn disarm(&mut self) {
457        self.armed = false;
458    }
459}
460
461impl Drop for NewFolderGuard<'_> {
462    fn drop(&mut self) {
463        if self.armed
464            && let Err(err) = std::fs::remove_dir_all(self.path)
465            && err.kind() != ErrorKind::NotFound
466        {
467            warn!(%err, path = %self.path.display(), "failed to remove dependency after installation failure");
468        }
469    }
470}
471
472fn restore_file(path: &Path, contents: Option<&[u8]>) {
473    let result = match contents {
474        Some(contents) => fs::write(path, contents),
475        None if path.exists() => fs::remove_file(path),
476        None => Ok(()),
477    };
478    if let Err(err) = result {
479        warn!(%err, path = %path.display(), "failed to restore file after installation failure");
480    }
481}
482
483impl Installer<'_> {
484    /// Installs the dependency as an ordinary folder instead of a submodule
485    fn install_as_folder(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
486        let url = dep.require_url()?;
487        let mut guard = NewFolderGuard::new(path)?;
488        Git::clone(dep.tag.is_none(), url, Some(&path))?;
489        let mut dep = dep.clone();
490
491        if dep.tag.is_none() {
492            // try to find latest semver release tag
493            dep.tag = self.last_tag(path);
494        }
495
496        // checkout the tag if necessary, using recursive checkout to properly clean up
497        // nested submodules that may exist on the default branch but not on the target tag.
498        // See: https://github.com/foundry-rs/foundry/issues/13688
499        self.git_checkout(&dep, path, true)?;
500
501        trace!("updating dependency submodules recursively");
502        self.git.root(path).submodule_update(
503            false,
504            false,
505            false,
506            true,
507            std::iter::empty::<PathBuf>(),
508        )?;
509
510        // remove nested .git directories from submodules before removing the top-level .git
511        Self::remove_nested_git_dirs(path)?;
512
513        // remove git artifacts
514        fs::remove_dir_all(path.join(".git"))?;
515
516        guard.disarm();
517        Ok(dep.tag)
518    }
519
520    /// Recursively removes `.git` files/directories from nested submodules within `root`.
521    ///
522    /// Submodules typically have a `.git` file (not a directory) pointing to the parent's
523    /// `.git/modules/` directory. This cleans those up so the result is a plain folder tree.
524    fn remove_nested_git_dirs(root: &Path) -> Result<()> {
525        Self::remove_nested_git_dirs_inner(root, root)
526    }
527
528    fn remove_nested_git_dirs_inner(root: &Path, dir: &Path) -> Result<()> {
529        let entries = match std::fs::read_dir(dir) {
530            Ok(entries) => entries,
531            Err(_) => return Ok(()),
532        };
533        for entry in entries {
534            let entry = entry?;
535            let ft = entry.file_type()?;
536
537            // never follow symlinks
538            if ft.is_symlink() {
539                continue;
540            }
541
542            let path = entry.path();
543            if path.file_name() == Some(".git".as_ref()) && path.parent() != Some(root) {
544                if ft.is_dir() {
545                    fs::remove_dir_all(&path)?;
546                } else {
547                    fs::remove_file(&path)?;
548                }
549            } else if ft.is_dir() {
550                Self::remove_nested_git_dirs_inner(root, &path)?;
551            }
552        }
553        Ok(())
554    }
555
556    /// Installs the dependency as new submodule.
557    ///
558    /// This will add the git submodule to the given dir, initialize it and checkout the tag if
559    /// provided or try to find the latest semver, release tag.
560    fn install_as_submodule(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
561        let root = Git::root_of(self.git.root)?;
562        let relative_path = path.strip_prefix(&root)?;
563        let git = self.git.root(&root);
564        let gitmodules = root.join(".gitmodules");
565        let gitmodules_contents = gitmodules.exists().then(|| fs::read(&gitmodules)).transpose()?;
566        let has_mapping = git.has_submodule_mapping(relative_path)?;
567        let is_gitlink = git.is_gitlink(relative_path)?;
568        if has_mapping != is_gitlink {
569            eyre::bail!(
570                "cannot safely install dependency at {} because .gitmodules already contains a matching submodule",
571                relative_path.display()
572            );
573        }
574        let mut guard = if is_gitlink {
575            None
576        } else {
577            let module_dir = git.absolute_git_dir()?.join("modules").join(relative_path);
578            let gitmodules_safe = !gitmodules.is_symlink()
579                && if gitmodules.exists() {
580                    git.is_normal_tracked_file(Path::new(".gitmodules"))?
581                } else {
582                    !git.has_index_entries(Path::new(".gitmodules"))?
583                };
584            let can_rollback = !path.is_symlink()
585                && !path.exists()
586                && !module_dir.exists()
587                && !git.has_index_entries(relative_path)?
588                && !git.has_submodule_config(relative_path)?
589                && git.is_path_clean(relative_path)?
590                && gitmodules_safe;
591            if !can_rollback {
592                eyre::bail!(
593                    "cannot safely install dependency at {} because the target or .gitmodules has existing changes",
594                    relative_path.display()
595                );
596            }
597            Some(NewSubmoduleGuard {
598                root,
599                relative_path: relative_path.to_path_buf(),
600                path: path.to_path_buf(),
601                module_dir,
602                gitmodules_contents,
603                armed: true,
604            })
605        };
606
607        // install the dep
608        self.git_submodule(dep, path)?;
609
610        let mut dep = dep.clone();
611        if dep.tag.is_none() {
612            // try to find latest semver release tag
613            dep.tag = self.last_tag(path);
614        }
615
616        // checkout the tag if necessary
617        self.git_checkout(&dep, path, true)?;
618
619        trace!("updating dependency submodules recursively");
620        self.git.root(path).submodule_update(
621            false,
622            false,
623            false,
624            true,
625            std::iter::empty::<PathBuf>(),
626        )?;
627
628        // sync submodules config with changes in .gitmodules, see <https://github.com/foundry-rs/foundry/issues/9611>
629        self.git.root(path).submodule_sync()?;
630
631        if let Some(guard) = &mut guard {
632            guard.disarm();
633        }
634        if self.commit {
635            self.git.add_literal(path)?;
636        }
637
638        Ok(dep.tag)
639    }
640
641    fn last_tag(self, path: &Path) -> Option<String> {
642        if self.git.shallow {
643            None
644        } else {
645            self.git_semver_tags(path).ok().and_then(|mut tags| tags.pop()).map(|(tag, _)| tag)
646        }
647    }
648
649    /// Returns all semver git tags sorted in ascending order
650    fn git_semver_tags(self, path: &Path) -> Result<Vec<(String, Version)>> {
651        let out = self.git.root(path).tag()?;
652        let mut tags = Vec::new();
653        // tags are commonly prefixed which would make them not semver: v1.2.3 is not a semantic
654        // version
655        let common_prefixes = &["v-", "v", "release-", "release"];
656        for tag in out.lines() {
657            let mut maybe_semver = tag;
658            for &prefix in common_prefixes {
659                if let Some(rem) = tag.strip_prefix(prefix) {
660                    maybe_semver = rem;
661                    break;
662                }
663            }
664            match Version::parse(maybe_semver) {
665                Ok(v) => {
666                    // ignore if additional metadata, like rc, beta, etc...
667                    if v.build.is_empty() && v.pre.is_empty() {
668                        tags.push((tag.to_string(), v));
669                    }
670                }
671                Err(err) => {
672                    warn!(?err, ?maybe_semver, "No semver tag");
673                }
674            }
675        }
676
677        tags.sort_by(|(_, a), (_, b)| a.cmp(b));
678
679        Ok(tags)
680    }
681
682    /// Install the given dependency as git submodule in `target_dir`.
683    fn git_submodule(self, dep: &Dependency, path: &Path) -> Result<()> {
684        let url = dep.require_url()?;
685
686        // make path relative to the git root, already checked above
687        let path = path.strip_prefix(self.git.root).unwrap();
688
689        trace!(?dep, url, ?path, "installing git submodule");
690        self.git.submodule_add(true, url, path)
691    }
692
693    fn git_checkout(self, dep: &Dependency, path: &Path, recurse: bool) -> Result<String> {
694        // no need to checkout if there is no tag
695        let Some(mut tag) = dep.tag.clone() else { return Ok(String::new()) };
696
697        let mut is_branch = false;
698        // only try to match tag if current terminal is a tty
699        if std::io::stdout().is_terminal() {
700            if tag.is_empty() {
701                tag = self.match_tag(&tag, path)?;
702            } else if let Some(branch) = self.match_branch(&tag, path)? {
703                trace!(?tag, ?branch, "selecting branch for given tag");
704                tag = branch;
705                is_branch = true;
706            }
707        }
708        let url = dep.url.as_ref().unwrap();
709
710        let res = self.git.root(path).checkout(recurse, &tag);
711        if let Err(mut e) = res {
712            // remove dependency on failed checkout
713            fs::remove_dir_all(path)?;
714            let error = e.to_string();
715            if error.contains("did not match any file(s) known to git")
716                || error.contains("fatal: invalid reference:")
717            {
718                e = eyre::eyre!("Tag: \"{tag}\" not found for repo \"{url}\"!")
719            }
720            return Err(e);
721        }
722
723        if is_branch { Ok(tag) } else { Ok(String::new()) }
724    }
725
726    /// disambiguate tag if it is a version tag
727    fn match_tag(self, tag: &str, path: &Path) -> Result<String> {
728        // only try to match if it looks like a version tag
729        if !DEPENDENCY_VERSION_TAG_REGEX.is_match(tag) {
730            return Ok(tag.into());
731        }
732
733        // generate candidate list by filtering `git tag` output, valid ones are those "starting
734        // with" the user-provided tag (ignoring the starting 'v'), for example, if the user
735        // specifies 1.5, then v1.5.2 is a valid candidate, but v3.1.5 is not
736        let trimmed_tag = tag.trim_start_matches('v').to_string();
737        let output = self.git.root(path).tag()?;
738        let mut candidates: Vec<String> = output
739            .trim()
740            .lines()
741            .filter(|x| x.trim_start_matches('v').starts_with(&trimmed_tag))
742            .map(|x| x.to_string())
743            .rev()
744            .collect();
745
746        // no match found, fall back to the user-provided tag
747        if candidates.is_empty() {
748            return Ok(tag.into());
749        }
750
751        // have exact match
752        for candidate in &candidates {
753            if candidate == tag {
754                return Ok(tag.into());
755            }
756        }
757
758        // only one candidate, ask whether the user wants to accept or not
759        if candidates.len() == 1 {
760            let matched_tag = &candidates[0];
761            let input = prompt!(
762                "Found a similar version tag: {matched_tag}, do you want to use this instead? [Y/n] "
763            )?;
764            return if match_yn(input) { Ok(matched_tag.clone()) } else { Ok(tag.into()) };
765        }
766
767        // multiple candidates, ask the user to choose one or skip
768        candidates.insert(0, String::from("SKIP AND USE ORIGINAL TAG"));
769        sh_status!("There are multiple matching tags:")?;
770        for (i, candidate) in candidates.iter().enumerate() {
771            sh_status!("[{i}] {candidate}")?;
772        }
773
774        let n_candidates = candidates.len();
775        loop {
776            let input: String =
777                prompt!("Please select a tag (0-{}, default: 1): ", n_candidates - 1)?;
778            let s = input.trim();
779            // default selection, return first candidate
780            let n = if s.is_empty() { Ok(1) } else { s.parse() };
781            // match user input, 0 indicates skipping and use original tag
782            match n {
783                Ok(0) => return Ok(tag.into()),
784                Ok(i) if (1..=n_candidates).contains(&i) => {
785                    let c = &candidates[i];
786                    sh_status!("[{i}] {c} selected")?;
787                    return Ok(c.clone());
788                }
789                _ => {}
790            }
791        }
792    }
793
794    fn match_branch(self, tag: &str, path: &Path) -> Result<Option<String>> {
795        // fetch remote branches and check for tag
796        let output = self.git.root(path).remote_branches()?;
797
798        let mut candidates = output
799            .lines()
800            .map(|x| x.trim().trim_start_matches("origin/"))
801            .filter(|x| x.starts_with(tag))
802            .map(ToString::to_string)
803            .rev()
804            .collect::<Vec<_>>();
805
806        trace!(?candidates, ?tag, "found branch candidates");
807
808        // no match found, fall back to the user-provided tag
809        if candidates.is_empty() {
810            return Ok(None);
811        }
812
813        // have exact match
814        for candidate in &candidates {
815            if candidate == tag {
816                return Ok(Some(tag.to_string()));
817            }
818        }
819
820        // only one candidate, ask whether the user wants to accept or not
821        if candidates.len() == 1 {
822            let matched_tag = &candidates[0];
823            let input = prompt!(
824                "Found a similar branch: {matched_tag}, do you want to use this instead? [Y/n] "
825            )?;
826            return if match_yn(input) { Ok(Some(matched_tag.clone())) } else { Ok(None) };
827        }
828
829        // multiple candidates, ask the user to choose one or skip
830        candidates.insert(0, format!("{tag} (original branch)"));
831        sh_status!("There are multiple matching branches:")?;
832        for (i, candidate) in candidates.iter().enumerate() {
833            sh_status!("[{i}] {candidate}")?;
834        }
835
836        let n_candidates = candidates.len();
837        let input: String = prompt!(
838            "Please select a tag (0-{}, default: 1, Press <enter> to cancel): ",
839            n_candidates - 1
840        )?;
841        let input = input.trim();
842
843        // default selection, return None
844        if input.is_empty() {
845            sh_status!("Canceled branch matching")?;
846            return Ok(None);
847        }
848
849        // match user input, 0 indicates skipping and use original tag
850        match input.parse::<usize>() {
851            Ok(0) => Ok(Some(tag.into())),
852            Ok(i) if (1..=n_candidates).contains(&i) => {
853                let c = &candidates[i];
854                sh_status!("[{i}] {c} selected")?;
855                Ok(Some(c.clone()))
856            }
857            _ => Ok(None),
858        }
859    }
860}
861
862/// Matches on the result of a prompt for yes/no.
863///
864/// Defaults to true.
865fn match_yn(input: String) -> bool {
866    let s = input.trim().to_lowercase();
867    matches!(s.as_str(), "" | "y" | "yes")
868}
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873    use tempfile::tempdir;
874
875    #[test]
876    #[ignore = "slow"]
877    fn get_oz_tags() {
878        let tmp = tempdir().unwrap();
879        let git = Git::new(tmp.path());
880        let installer = Installer { git, commit: false };
881
882        git.init().unwrap();
883
884        let dep: Dependency = "openzeppelin/openzeppelin-contracts".parse().unwrap();
885        let libs = tmp.path().join("libs");
886        fs::create_dir(&libs).unwrap();
887        let submodule = libs.join("openzeppelin-contracts");
888        installer.git_submodule(&dep, &submodule).unwrap();
889        assert!(submodule.exists());
890
891        let tags = installer.git_semver_tags(&submodule).unwrap();
892        assert!(!tags.is_empty());
893        let v480: Version = "4.8.0".parse().unwrap();
894        assert!(tags.iter().any(|(_, v)| v == &v480));
895    }
896}