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::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            if let Err(e) = install_soldeer_deps_if_needed(&path).await {
325                sh_warn!("Failed to install soldeer dependencies for {}: {e}", dep.name)?;
326            }
327        }
328
329        // update `libs` in config if not included yet
330        if !config.libs.iter().any(|p| p == install_lib_dir) {
331            config.libs.push(install_lib_dir.to_path_buf());
332            config.update_libs()?;
333        }
334
335        Ok(())
336    }
337}
338
339/// Installs missing dependencies and reloads config only to discover new remappings.
340pub fn install_missing_dependencies<E>(
341    config: &mut Config,
342    reload: impl FnOnce() -> Result<Config, E>,
343) -> Result<(), E> {
344    if DependencyInstallOpts::default().install_missing_dependencies(config)
345        && config.auto_detect_remappings
346    {
347        *config = reload()?;
348    }
349    Ok(())
350}
351
352/// Checks if a dependency has soldeer.lock and installs soldeer dependencies if needed.
353async fn install_soldeer_deps_if_needed(dep_path: &Path) -> Result<()> {
354    let soldeer_lock = dep_path.join("soldeer.lock");
355
356    if soldeer_lock.exists() {
357        sh_status!("    Found soldeer.lock, installing soldeer dependencies...")?;
358
359        // Change to the dependency directory and run soldeer install
360        let original_dir = std::env::current_dir()?;
361        std::env::set_current_dir(dep_path)?;
362
363        let result = soldeer_commands::run(
364            Command::Install(Install::default()),
365            Verbosity::new(
366                foundry_common::shell::verbosity(),
367                if foundry_common::shell::is_quiet() { 1 } else { 0 },
368            ),
369        )
370        .await;
371
372        // Change back to original directory
373        std::env::set_current_dir(original_dir)?;
374
375        result.map_err(|e| eyre::eyre!("Failed to run soldeer install: {e}"))?;
376        sh_status!("    Soldeer dependencies installed successfully")?;
377    }
378
379    Ok(())
380}
381
382#[derive(Clone, Copy, Debug)]
383struct Installer<'a> {
384    git: Git<'a>,
385    commit: bool,
386}
387
388struct NewSubmoduleGuard {
389    root: PathBuf,
390    relative_path: PathBuf,
391    path: PathBuf,
392    module_dir: PathBuf,
393    gitmodules_contents: Option<Vec<u8>>,
394    armed: bool,
395}
396
397impl NewSubmoduleGuard {
398    const fn disarm(&mut self) {
399        self.armed = false;
400    }
401
402    fn rollback(&self) {
403        let git = Git::new(&self.root);
404        if let Err(err) = git.remove_index_path(&self.relative_path) {
405            warn!(%err, "failed to remove submodule after installation failure");
406        }
407        if self.path.exists()
408            && let Err(err) = fs::remove_dir_all(&self.path)
409        {
410            warn!(%err, "failed to remove dependency after installation failure");
411        }
412        if self.module_dir.exists()
413            && let Err(err) = fs::remove_dir_all(&self.module_dir)
414        {
415            warn!(%err, "failed to remove submodule Git directory after installation failure");
416        }
417        if let Err(err) = git.remove_submodule_config(&self.relative_path) {
418            warn!(%err, "failed to remove submodule config after installation failure");
419        }
420        restore_file(&self.root.join(".gitmodules"), self.gitmodules_contents.as_deref());
421        if let Err(err) = git.add_literal(Path::new(".gitmodules")) {
422            warn!(%err, "failed to restore staged .gitmodules after installation failure");
423        }
424    }
425}
426
427impl Drop for NewSubmoduleGuard {
428    fn drop(&mut self) {
429        if self.armed {
430            self.rollback();
431        }
432    }
433}
434
435fn restore_file(path: &Path, contents: Option<&[u8]>) {
436    let result = match contents {
437        Some(contents) => fs::write(path, contents),
438        None if path.exists() => fs::remove_file(path),
439        None => Ok(()),
440    };
441    if let Err(err) = result {
442        warn!(%err, path = %path.display(), "failed to restore file after installation failure");
443    }
444}
445
446impl Installer<'_> {
447    /// Installs the dependency as an ordinary folder instead of a submodule
448    fn install_as_folder(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
449        let url = dep.require_url()?;
450        Git::clone(dep.tag.is_none(), url, Some(&path))?;
451        let mut dep = dep.clone();
452
453        if dep.tag.is_none() {
454            // try to find latest semver release tag
455            dep.tag = self.last_tag(path);
456        }
457
458        // checkout the tag if necessary, using recursive checkout to properly clean up
459        // nested submodules that may exist on the default branch but not on the target tag.
460        // See: https://github.com/foundry-rs/foundry/issues/13688
461        self.git_checkout(&dep, path, true)?;
462
463        trace!("updating dependency submodules recursively");
464        self.git.root(path).submodule_update(
465            false,
466            false,
467            false,
468            true,
469            std::iter::empty::<PathBuf>(),
470        )?;
471
472        // remove nested .git directories from submodules before removing the top-level .git
473        Self::remove_nested_git_dirs(path)?;
474
475        // remove git artifacts
476        fs::remove_dir_all(path.join(".git"))?;
477
478        Ok(dep.tag)
479    }
480
481    /// Recursively removes `.git` files/directories from nested submodules within `root`.
482    ///
483    /// Submodules typically have a `.git` file (not a directory) pointing to the parent's
484    /// `.git/modules/` directory. This cleans those up so the result is a plain folder tree.
485    fn remove_nested_git_dirs(root: &Path) -> Result<()> {
486        Self::remove_nested_git_dirs_inner(root, root)
487    }
488
489    fn remove_nested_git_dirs_inner(root: &Path, dir: &Path) -> Result<()> {
490        let entries = match std::fs::read_dir(dir) {
491            Ok(entries) => entries,
492            Err(_) => return Ok(()),
493        };
494        for entry in entries {
495            let entry = entry?;
496            let ft = entry.file_type()?;
497
498            // never follow symlinks
499            if ft.is_symlink() {
500                continue;
501            }
502
503            let path = entry.path();
504            if path.file_name() == Some(".git".as_ref()) && path.parent() != Some(root) {
505                if ft.is_dir() {
506                    fs::remove_dir_all(&path)?;
507                } else {
508                    fs::remove_file(&path)?;
509                }
510            } else if ft.is_dir() {
511                Self::remove_nested_git_dirs_inner(root, &path)?;
512            }
513        }
514        Ok(())
515    }
516
517    /// Installs the dependency as new submodule.
518    ///
519    /// This will add the git submodule to the given dir, initialize it and checkout the tag if
520    /// provided or try to find the latest semver, release tag.
521    fn install_as_submodule(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
522        let root = Git::root_of(self.git.root)?;
523        let relative_path = path.strip_prefix(&root)?;
524        let git = self.git.root(&root);
525        let gitmodules = root.join(".gitmodules");
526        let gitmodules_contents = gitmodules.exists().then(|| fs::read(&gitmodules)).transpose()?;
527        let has_mapping = git.has_submodule_mapping(relative_path)?;
528        let is_gitlink = git.is_gitlink(relative_path)?;
529        if has_mapping != is_gitlink {
530            eyre::bail!(
531                "cannot safely install dependency at {} because .gitmodules already contains a matching submodule",
532                relative_path.display()
533            );
534        }
535        let mut guard = if is_gitlink {
536            None
537        } else {
538            let module_dir = git.absolute_git_dir()?.join("modules").join(relative_path);
539            let gitmodules_safe = !gitmodules.is_symlink()
540                && if gitmodules.exists() {
541                    git.is_normal_tracked_file(Path::new(".gitmodules"))?
542                } else {
543                    !git.has_index_entries(Path::new(".gitmodules"))?
544                };
545            let can_rollback = !path.is_symlink()
546                && !path.exists()
547                && !module_dir.exists()
548                && !git.has_index_entries(relative_path)?
549                && !git.has_submodule_config(relative_path)?
550                && git.is_path_clean(relative_path)?
551                && gitmodules_safe;
552            if !can_rollback {
553                eyre::bail!(
554                    "cannot safely install dependency at {} because the target or .gitmodules has existing changes",
555                    relative_path.display()
556                );
557            }
558            Some(NewSubmoduleGuard {
559                root,
560                relative_path: relative_path.to_path_buf(),
561                path: path.to_path_buf(),
562                module_dir,
563                gitmodules_contents,
564                armed: true,
565            })
566        };
567
568        // install the dep
569        self.git_submodule(dep, path)?;
570
571        let mut dep = dep.clone();
572        if dep.tag.is_none() {
573            // try to find latest semver release tag
574            dep.tag = self.last_tag(path);
575        }
576
577        // checkout the tag if necessary
578        self.git_checkout(&dep, path, true)?;
579
580        trace!("updating dependency submodules recursively");
581        self.git.root(path).submodule_update(
582            false,
583            false,
584            false,
585            true,
586            std::iter::empty::<PathBuf>(),
587        )?;
588
589        // sync submodules config with changes in .gitmodules, see <https://github.com/foundry-rs/foundry/issues/9611>
590        self.git.root(path).submodule_sync()?;
591
592        if let Some(guard) = &mut guard {
593            guard.disarm();
594        }
595        if self.commit {
596            self.git.add_literal(path)?;
597        }
598
599        Ok(dep.tag)
600    }
601
602    fn last_tag(self, path: &Path) -> Option<String> {
603        if self.git.shallow {
604            None
605        } else {
606            self.git_semver_tags(path).ok().and_then(|mut tags| tags.pop()).map(|(tag, _)| tag)
607        }
608    }
609
610    /// Returns all semver git tags sorted in ascending order
611    fn git_semver_tags(self, path: &Path) -> Result<Vec<(String, Version)>> {
612        let out = self.git.root(path).tag()?;
613        let mut tags = Vec::new();
614        // tags are commonly prefixed which would make them not semver: v1.2.3 is not a semantic
615        // version
616        let common_prefixes = &["v-", "v", "release-", "release"];
617        for tag in out.lines() {
618            let mut maybe_semver = tag;
619            for &prefix in common_prefixes {
620                if let Some(rem) = tag.strip_prefix(prefix) {
621                    maybe_semver = rem;
622                    break;
623                }
624            }
625            match Version::parse(maybe_semver) {
626                Ok(v) => {
627                    // ignore if additional metadata, like rc, beta, etc...
628                    if v.build.is_empty() && v.pre.is_empty() {
629                        tags.push((tag.to_string(), v));
630                    }
631                }
632                Err(err) => {
633                    warn!(?err, ?maybe_semver, "No semver tag");
634                }
635            }
636        }
637
638        tags.sort_by(|(_, a), (_, b)| a.cmp(b));
639
640        Ok(tags)
641    }
642
643    /// Install the given dependency as git submodule in `target_dir`.
644    fn git_submodule(self, dep: &Dependency, path: &Path) -> Result<()> {
645        let url = dep.require_url()?;
646
647        // make path relative to the git root, already checked above
648        let path = path.strip_prefix(self.git.root).unwrap();
649
650        trace!(?dep, url, ?path, "installing git submodule");
651        self.git.submodule_add(true, url, path)
652    }
653
654    fn git_checkout(self, dep: &Dependency, path: &Path, recurse: bool) -> Result<String> {
655        // no need to checkout if there is no tag
656        let Some(mut tag) = dep.tag.clone() else { return Ok(String::new()) };
657
658        let mut is_branch = false;
659        // only try to match tag if current terminal is a tty
660        if std::io::stdout().is_terminal() {
661            if tag.is_empty() {
662                tag = self.match_tag(&tag, path)?;
663            } else if let Some(branch) = self.match_branch(&tag, path)? {
664                trace!(?tag, ?branch, "selecting branch for given tag");
665                tag = branch;
666                is_branch = true;
667            }
668        }
669        let url = dep.url.as_ref().unwrap();
670
671        let res = self.git.root(path).checkout(recurse, &tag);
672        if let Err(mut e) = res {
673            // remove dependency on failed checkout
674            fs::remove_dir_all(path)?;
675            if e.to_string().contains("did not match any file(s) known to git") {
676                e = eyre::eyre!("Tag: \"{tag}\" not found for repo \"{url}\"!")
677            }
678            return Err(e);
679        }
680
681        if is_branch { Ok(tag) } else { Ok(String::new()) }
682    }
683
684    /// disambiguate tag if it is a version tag
685    fn match_tag(self, tag: &str, path: &Path) -> Result<String> {
686        // only try to match if it looks like a version tag
687        if !DEPENDENCY_VERSION_TAG_REGEX.is_match(tag) {
688            return Ok(tag.into());
689        }
690
691        // generate candidate list by filtering `git tag` output, valid ones are those "starting
692        // with" the user-provided tag (ignoring the starting 'v'), for example, if the user
693        // specifies 1.5, then v1.5.2 is a valid candidate, but v3.1.5 is not
694        let trimmed_tag = tag.trim_start_matches('v').to_string();
695        let output = self.git.root(path).tag()?;
696        let mut candidates: Vec<String> = output
697            .trim()
698            .lines()
699            .filter(|x| x.trim_start_matches('v').starts_with(&trimmed_tag))
700            .map(|x| x.to_string())
701            .rev()
702            .collect();
703
704        // no match found, fall back to the user-provided tag
705        if candidates.is_empty() {
706            return Ok(tag.into());
707        }
708
709        // have exact match
710        for candidate in &candidates {
711            if candidate == tag {
712                return Ok(tag.into());
713            }
714        }
715
716        // only one candidate, ask whether the user wants to accept or not
717        if candidates.len() == 1 {
718            let matched_tag = &candidates[0];
719            let input = prompt!(
720                "Found a similar version tag: {matched_tag}, do you want to use this instead? [Y/n] "
721            )?;
722            return if match_yn(input) { Ok(matched_tag.clone()) } else { Ok(tag.into()) };
723        }
724
725        // multiple candidates, ask the user to choose one or skip
726        candidates.insert(0, String::from("SKIP AND USE ORIGINAL TAG"));
727        sh_status!("There are multiple matching tags:")?;
728        for (i, candidate) in candidates.iter().enumerate() {
729            sh_status!("[{i}] {candidate}")?;
730        }
731
732        let n_candidates = candidates.len();
733        loop {
734            let input: String =
735                prompt!("Please select a tag (0-{}, default: 1): ", n_candidates - 1)?;
736            let s = input.trim();
737            // default selection, return first candidate
738            let n = if s.is_empty() { Ok(1) } else { s.parse() };
739            // match user input, 0 indicates skipping and use original tag
740            match n {
741                Ok(0) => return Ok(tag.into()),
742                Ok(i) if (1..=n_candidates).contains(&i) => {
743                    let c = &candidates[i];
744                    sh_status!("[{i}] {c} selected")?;
745                    return Ok(c.clone());
746                }
747                _ => {}
748            }
749        }
750    }
751
752    fn match_branch(self, tag: &str, path: &Path) -> Result<Option<String>> {
753        // fetch remote branches and check for tag
754        let output = self.git.root(path).remote_branches()?;
755
756        let mut candidates = output
757            .lines()
758            .map(|x| x.trim().trim_start_matches("origin/"))
759            .filter(|x| x.starts_with(tag))
760            .map(ToString::to_string)
761            .rev()
762            .collect::<Vec<_>>();
763
764        trace!(?candidates, ?tag, "found branch candidates");
765
766        // no match found, fall back to the user-provided tag
767        if candidates.is_empty() {
768            return Ok(None);
769        }
770
771        // have exact match
772        for candidate in &candidates {
773            if candidate == tag {
774                return Ok(Some(tag.to_string()));
775            }
776        }
777
778        // only one candidate, ask whether the user wants to accept or not
779        if candidates.len() == 1 {
780            let matched_tag = &candidates[0];
781            let input = prompt!(
782                "Found a similar branch: {matched_tag}, do you want to use this instead? [Y/n] "
783            )?;
784            return if match_yn(input) { Ok(Some(matched_tag.clone())) } else { Ok(None) };
785        }
786
787        // multiple candidates, ask the user to choose one or skip
788        candidates.insert(0, format!("{tag} (original branch)"));
789        sh_status!("There are multiple matching branches:")?;
790        for (i, candidate) in candidates.iter().enumerate() {
791            sh_status!("[{i}] {candidate}")?;
792        }
793
794        let n_candidates = candidates.len();
795        let input: String = prompt!(
796            "Please select a tag (0-{}, default: 1, Press <enter> to cancel): ",
797            n_candidates - 1
798        )?;
799        let input = input.trim();
800
801        // default selection, return None
802        if input.is_empty() {
803            sh_status!("Canceled branch matching")?;
804            return Ok(None);
805        }
806
807        // match user input, 0 indicates skipping and use original tag
808        match input.parse::<usize>() {
809            Ok(0) => Ok(Some(tag.into())),
810            Ok(i) if (1..=n_candidates).contains(&i) => {
811                let c = &candidates[i];
812                sh_status!("[{i}] {c} selected")?;
813                Ok(Some(c.clone()))
814            }
815            _ => Ok(None),
816        }
817    }
818}
819
820/// Matches on the result of a prompt for yes/no.
821///
822/// Defaults to true.
823fn match_yn(input: String) -> bool {
824    let s = input.trim().to_lowercase();
825    matches!(s.as_str(), "" | "y" | "yes")
826}
827
828#[cfg(test)]
829mod tests {
830    use super::*;
831    use tempfile::tempdir;
832
833    #[test]
834    #[ignore = "slow"]
835    fn get_oz_tags() {
836        let tmp = tempdir().unwrap();
837        let git = Git::new(tmp.path());
838        let installer = Installer { git, commit: false };
839
840        git.init().unwrap();
841
842        let dep: Dependency = "openzeppelin/openzeppelin-contracts".parse().unwrap();
843        let libs = tmp.path().join("libs");
844        fs::create_dir(&libs).unwrap();
845        let submodule = libs.join("openzeppelin-contracts");
846        installer.git_submodule(&dep, &submodule).unwrap();
847        assert!(submodule.exists());
848
849        let tags = installer.git_semver_tags(&submodule).unwrap();
850        assert!(!tags.is_empty());
851        let v480: Version = "4.8.0".parse().unwrap();
852        assert!(tags.iter().any(|(_, v)| v == &v480));
853    }
854}