Skip to main content

soldeer_core/
config.rs

1//! Manage the Soldeer configuration and dependencies list.
2use crate::{
3    download::{find_install_path, find_install_path_sync},
4    errors::ConfigError,
5    lock::SOLDEER_LOCK,
6    remappings::RemappingsLocation,
7};
8use derive_more::derive::{Display, From, FromStr};
9use log::{debug, warn};
10use serde::Deserialize;
11use std::{
12    env, fmt, fs,
13    path::{Path, PathBuf},
14};
15use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, value};
16
17pub type Result<T> = std::result::Result<T, ConfigError>;
18
19#[derive(Debug, Clone, PartialEq, Eq, Hash, Display)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21pub enum UrlType {
22    Git(String),
23    Http(String),
24}
25
26impl UrlType {
27    pub fn git(url: impl Into<String>) -> Self {
28        Self::Git(url.into())
29    }
30
31    pub fn http(url: impl Into<String>) -> Self {
32        Self::Http(url.into())
33    }
34}
35
36/// The paths used by Soldeer.
37///
38/// The paths are canonicalized on creation of the object.
39///
40/// To create this object, the [`Paths::new`] and [`Paths::from_root`] methods can be used.
41///
42/// # Examples
43///
44/// ```
45/// # use soldeer_core::config::Paths;
46/// # let dir = testdir::testdir!();
47/// # std::env::set_current_dir(&dir).unwrap();
48/// # std::fs::write("foundry.toml", "[dependencies]\n").unwrap();
49/// let paths = Paths::new().unwrap(); // foundry.toml exists in the current path
50/// assert_eq!(paths.root, std::env::current_dir().unwrap());
51/// assert_eq!(paths.config, std::env::current_dir().unwrap().join("foundry.toml"));
52///
53/// let paths = Paths::from_root(&dir).unwrap(); // root is the given path
54/// assert_eq!(paths.root, dir);
55/// ```
56#[derive(Debug, Clone, PartialEq, Eq, Hash)]
57#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))]
58// making sure the struct is not constructible from the outside without using the new/from methods
59#[non_exhaustive]
60pub struct Paths {
61    /// The root directory of the project.
62    ///
63    /// At the moment, the current directory or the path given by the `SOLDEER_PROJECT_ROOT`
64    /// environment variable.
65    pub root: PathBuf,
66
67    /// The path to the config file.
68    ///
69    /// `foundry.toml` if it contains a `[dependencies]` table, otherwise `soldeer.toml` if it
70    /// exists. Otherwise, the `foundry.toml` file is used by default. When the config file does
71    /// not exist, a new one is created with default contents.
72    pub config: PathBuf,
73
74    /// The path to the dependencies folder (does not need to exist).
75    ///
76    /// This is `/dependencies` inside the root directory.
77    pub dependencies: PathBuf,
78
79    /// The path to the lockfile (does not need to exist).
80    ///
81    /// This is `/soldeer.lock` inside the root directory.
82    pub lock: PathBuf,
83
84    /// The path to the remappings file (does not need to exist).
85    ///
86    /// This path gets ignored if the remappings should be generated in the `foundry.toml` file.
87    /// This is `/remappings.txt` inside the root directory.
88    pub remappings: PathBuf,
89}
90
91impl Paths {
92    /// Instantiate all the paths needed for Soldeer.
93    ///
94    /// The root path defaults to the current directory but can be overridden with the
95    /// `SOLDEER_PROJECT_ROOT` environment variable.
96    ///
97    /// The paths are canonicalized.
98    pub fn new() -> Result<Self> {
99        Self::with_config(None)
100    }
101
102    /// Instantiate all the paths needed for Soldeer.
103    ///
104    /// The root path is automatically detected (by traversing the path) but can be overridden with
105    /// the `SOLDEER_PROJECT_ROOT` environment variable.
106    /// Alternatively, the [`Paths::with_root_and_config`] constructor can be used.
107    ///
108    /// If a config location is provided, it bypasses auto-detection and uses that. If `None`, then
109    /// the location is auto-detected or if impossible, the `foundry.toml` file is used. If the
110    /// config file does not exist yet, it gets created with default content.
111    ///
112    /// The paths are canonicalized.
113    pub fn with_config(config_location: Option<ConfigLocation>) -> Result<Self> {
114        let root = dunce::canonicalize(Self::get_root_path())?;
115        Self::with_root_and_config(root, config_location)
116    }
117
118    /// Instantiate all the paths needed for Soldeer.
119    ///
120    /// If a config location is provided, it bypasses auto-detection and uses that. If `None`, then
121    /// the location is auto-detected or if impossible, the `foundry.toml` file is used. If the
122    /// config file does not exist yet, it gets created with default content.
123    ///
124    /// The paths are canonicalized.
125    pub fn with_root_and_config(
126        root: impl AsRef<Path>,
127        config_location: Option<ConfigLocation>,
128    ) -> Result<Self> {
129        let root = root.as_ref();
130        let config = Self::get_config_path(root, config_location)?;
131        let dependencies = root.join("dependencies");
132        let lock = root.join(SOLDEER_LOCK);
133        let remappings = root.join("remappings.txt");
134
135        Ok(Self { root: root.to_path_buf(), config, dependencies, lock, remappings })
136    }
137
138    /// Generate the paths object from a known root directory.
139    ///
140    /// The `SOLDEER_PROJECT_ROOT` environment variable is ignored.
141    ///
142    /// The paths are canonicalized.
143    pub fn from_root(root: impl AsRef<Path>) -> Result<Self> {
144        let root = dunce::canonicalize(root.as_ref())?;
145        let config = Self::get_config_path(&root, None)?;
146        let dependencies = root.join("dependencies");
147        let lock = root.join(SOLDEER_LOCK);
148        let remappings = root.join("remappings.txt");
149
150        Ok(Self { root, config, dependencies, lock, remappings })
151    }
152
153    /// Get the root directory path.
154    ///
155    /// If `SOLDEER_PROJECT` root is present in the environment, this is the returned value. Else,
156    /// we search for the root of the project with `find_project_root`.
157    pub fn get_root_path() -> PathBuf {
158        let res = env::var("SOLDEER_PROJECT_ROOT").map_or_else(
159            |_| {
160                debug!("SOLDEER_PROJECT_ROOT not defined, searching for project root");
161                find_project_root(None::<PathBuf>).expect("could not find project root")
162            },
163            |p| {
164                if p.is_empty() {
165                    debug!("SOLDEER_PROJECT_ROOT exists but is empty, searching for project root");
166                    find_project_root(None::<PathBuf>).expect("could not find project root")
167                } else {
168                    debug!(path = p; "root set by SOLDEER_PROJECT_ROOT");
169                    PathBuf::from(p)
170                }
171            },
172        );
173        debug!(path:? = res; "found project root");
174        res
175    }
176
177    /// Get the path to the config file.
178    ///
179    /// If a parameter is given for `config_location`, it will be used. Otherwise, the function will
180    /// try to auto-detect the location based on the existence of the `dependencies` entry in
181    /// the foundry config file, or the existence of a `soldeer.toml` file. If no config can be
182    /// found, `foundry.toml` is used by default.
183    fn get_config_path(
184        root: impl AsRef<Path>,
185        config_location: Option<ConfigLocation>,
186    ) -> Result<PathBuf> {
187        let foundry_path = root.as_ref().join("foundry.toml");
188        let soldeer_path = root.as_ref().join("soldeer.toml");
189        // use the user preference if available
190        let location = config_location.or_else(|| {
191            debug!("no preferred config location, trying to detect automatically");
192            detect_config_location(root)
193        }).unwrap_or_else(|| {
194            warn!("config file location could not be determined automatically, using foundry by default");
195            ConfigLocation::Foundry
196        });
197        debug!("using config location {location:?}");
198        create_or_modify_config(location, &foundry_path, &soldeer_path)
199    }
200
201    /// Default Foundry config file path
202    pub fn foundry_default() -> PathBuf {
203        let root: PathBuf =
204            dunce::canonicalize(Self::get_root_path()).expect("could not get the root");
205        root.join("foundry.toml")
206    }
207
208    /// Default Soldeer config file path
209    pub fn soldeer_default() -> PathBuf {
210        let root: PathBuf =
211            dunce::canonicalize(Self::get_root_path()).expect("could not get the root");
212        root.join("soldeer.toml")
213    }
214}
215
216/// For clap
217fn default_true() -> bool {
218    true
219}
220
221/// The Soldeer config options.
222#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
223#[cfg_attr(feature = "serde", derive(serde::Serialize))]
224pub struct SoldeerConfig {
225    /// Whether to generate remappings or completely leave them untouched.
226    ///
227    /// Defaults to `true`.
228    #[serde(default = "default_true")]
229    pub remappings_generate: bool,
230
231    /// Whether to regenerate the remappings every time and ignore existing content.
232    ///
233    /// Defaults to `false`.
234    #[serde(default)]
235    pub remappings_regenerate: bool,
236
237    /// Whether to include the version requirement string in the left part of the remappings.
238    ///
239    /// Defaults to `true`.
240    #[serde(default = "default_true")]
241    pub remappings_version: bool,
242
243    /// A prefix to add to each dependency name in the left part of the remappings.
244    ///
245    /// None by default.
246    #[serde(default)]
247    pub remappings_prefix: String,
248
249    /// The location where the remappings file should be generated.
250    ///
251    /// Either inside the `foundry.toml` config file or as a separate `remappings.txt` file.
252    /// This gets ignored if the config file is `soldeer.toml`, in which case the remappings
253    /// are always generated in a separate file.
254    ///
255    /// Defaults to [`RemappingsLocation::Txt`].
256    #[serde(default)]
257    pub remappings_location: RemappingsLocation,
258
259    /// Whether to include dependencies from dependencies.
260    ///
261    /// For dependencies which use soldeer, the `soldeer install` command will be invoked.
262    /// Git dependencies which have submodules will see their submodules cloned as well.
263    ///
264    /// Defaults to `false`.
265    #[serde(default)]
266    pub recursive_deps: bool,
267}
268
269impl Default for SoldeerConfig {
270    fn default() -> Self {
271        Self {
272            remappings_generate: true,
273            remappings_regenerate: false,
274            remappings_version: true,
275            remappings_prefix: String::new(),
276            remappings_location: RemappingsLocation::default(),
277            recursive_deps: false,
278        }
279    }
280}
281
282/// A git identifier used to specify a revision, branch or tag.
283///
284/// # Examples
285///
286/// ```
287/// # use soldeer_core::config::GitIdentifier;
288/// let rev = GitIdentifier::from_rev("082692fcb6b5b1ab8f856914897f7f2b46b84fd2");
289/// let branch = GitIdentifier::from_branch("feature/foo");
290/// let tag = GitIdentifier::from_tag("v1.0.0");
291/// ```
292#[derive(Debug, Clone, PartialEq, Eq, Hash, Display)]
293#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))]
294pub enum GitIdentifier {
295    /// A commit hash
296    Rev(String),
297
298    /// A branch name
299    Branch(String),
300
301    /// A tag name
302    Tag(String),
303}
304
305impl GitIdentifier {
306    /// Create a new git identifier from a revision hash.
307    pub fn from_rev(rev: impl Into<String>) -> Self {
308        let rev: String = rev.into();
309        Self::Rev(rev)
310    }
311
312    /// Create a new git identifier from a branch name.
313    pub fn from_branch(branch: impl Into<String>) -> Self {
314        let branch: String = branch.into();
315        Self::Branch(branch)
316    }
317
318    /// Create a new git identifier from a tag name.
319    pub fn from_tag(tag: impl Into<String>) -> Self {
320        let tag: String = tag.into();
321        Self::Tag(tag)
322    }
323}
324
325/// A git dependency config item.
326///
327/// This struct is used to represent a git dependency from the config file.
328#[derive(Debug, Clone, PartialEq, Eq, Hash, bon::Builder)]
329#[allow(clippy::duplicated_attributes)]
330#[builder(on(String, into), on(PathBuf, into))]
331#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))]
332pub struct GitDependency {
333    /// The name of the dependency (user-defined).
334    pub name: String,
335
336    /// The version requirement string (semver).
337    ///
338    /// Example: `>=1.9.3 || ^2.0.0`
339    ///
340    /// When no operator is used before the version number, it defaults to `=` which pins the
341    /// version.
342    #[cfg_attr(feature = "serde", serde(rename = "version"))]
343    pub version_req: String,
344
345    /// The git URL, must end with `.git`.
346    pub git: String,
347
348    /// The git identifier (revision, branch or tag).
349    ///
350    /// If omitted, the default branch is used.
351    pub identifier: Option<GitIdentifier>,
352
353    /// An optional relative path to the project's root within the repository.
354    ///
355    /// The project root is where the soldeer.toml or foundry.toml resides. If no path is provided,
356    /// then the repo's root must contain a Soldeer config.
357    pub project_root: Option<PathBuf>,
358}
359
360impl fmt::Display for GitDependency {
361    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> core::fmt::Result {
362        write!(f, "{}~{}", self.name, self.version_req)
363    }
364}
365
366/// An HTTP dependency config item.
367///
368/// This struct is used to represent an HTTP dependency from the config file.
369#[derive(Debug, Clone, PartialEq, Eq, Hash, bon::Builder)]
370#[allow(clippy::duplicated_attributes)]
371#[builder(on(String, into), on(PathBuf, into))]
372#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))]
373pub struct HttpDependency {
374    /// The name of the dependency (user-defined).
375    pub name: String,
376
377    /// The version requirement string (semver).
378    ///
379    /// Example: `>=1.9.3 || ^2.0.0`
380    ///
381    /// When no operator is used before the version number, it defaults to `=` which pins the
382    /// version.
383    #[cfg_attr(feature = "serde", serde(rename = "version"))]
384    pub version_req: String,
385
386    /// The URL to the dependency.
387    ///
388    /// If omitted, the registry will be contacted to get the download URL for that dependency (by
389    /// name).
390    pub url: Option<String>,
391
392    /// An optional relative path to the project's root within the zip file.
393    ///
394    /// The project root is where the soldeer.toml or foundry.toml resides. If no path is provided,
395    /// then the zip's root must contain a Soldeer config.
396    pub project_root: Option<PathBuf>,
397}
398
399impl fmt::Display for HttpDependency {
400    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> core::fmt::Result {
401        write!(f, "{}~{}", self.name, self.version_req)
402    }
403}
404
405/// A git or HTTP dependency config item.
406///
407/// A builder can be used to create the underlying [`HttpDependency`] or [`GitDependency`] and then
408/// converted into this type with `.into()`.
409///
410/// # Examples
411///
412/// ```
413/// # use soldeer_core::config::{Dependency, HttpDependency};
414/// let dep: Dependency = HttpDependency::builder()
415///     .name("my-dep")
416///     .version_req("^1.0.0")
417///     .url("https://...")
418///     .build()
419///     .into();
420/// ```
421#[derive(Debug, Clone, PartialEq, Eq, Hash, Display, From)]
422#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))]
423pub enum Dependency {
424    #[from(HttpDependency)]
425    Http(HttpDependency),
426
427    #[from(GitDependency)]
428    Git(GitDependency),
429}
430
431impl Dependency {
432    /// Create a new dependency from a name and version requirement string.
433    ///
434    /// The string should be in the format `name~version_req`.
435    ///
436    /// The version requirement string can use the semver format.
437    ///
438    /// Example: `dependency~^1.0.0`
439    ///
440    /// If a custom URL is provided, then the version requirement string
441    /// cannot contain the `=` character, as it would break the remappings.
442    ///
443    /// # Examples
444    ///
445    /// ```
446    /// # use soldeer_core::config::{Dependency, HttpDependency, GitDependency, GitIdentifier, UrlType};
447    /// assert_eq!(
448    ///     Dependency::from_name_version("my-lib~^1.0.0", Some(UrlType::http("https://foo.bar/zip.zip")), None)
449    ///         .unwrap(),
450    ///     HttpDependency::builder()
451    ///         .name("my-lib")
452    ///         .version_req("^1.0.0")
453    ///         .url("https://foo.bar/zip.zip")
454    ///         .build()
455    ///         .into()
456    /// );
457    /// assert_eq!(
458    ///     Dependency::from_name_version(
459    ///         "my-lib~^1.0.0",
460    ///         Some(UrlType::git("git@github.com:foo/bar.git")),
461    ///         Some(GitIdentifier::from_tag("v1.0.0")),
462    ///     )
463    ///     .unwrap(),
464    ///     GitDependency::builder()
465    ///         .name("my-lib")
466    ///         .version_req("^1.0.0")
467    ///         .git("git@github.com:foo/bar.git")
468    ///         .identifier(GitIdentifier::from_tag("v1.0.0"))
469    ///         .build()
470    ///         .into()
471    /// );
472    /// ```
473    pub fn from_name_version(
474        name_version: &str,
475        custom_url: Option<UrlType>,
476        identifier: Option<GitIdentifier>,
477    ) -> Result<Self> {
478        let (dependency_name, dependency_version_req) = name_version
479            .split_once('~')
480            .ok_or(ConfigError::InvalidNameAndVersion(name_version.to_string()))?;
481        if dependency_version_req.is_empty() {
482            return Err(ConfigError::EmptyVersion(dependency_name.to_string()));
483        }
484        Ok(match custom_url {
485            Some(url) => {
486                // in this case (custom url or git dependency), the version requirement string is
487                // going to be used as part of the folder name inside the
488                // dependencies folder. As such, it's not allowed to contain the "="
489                // character, because that would break the remappings.
490                if dependency_version_req.contains('=') {
491                    return Err(ConfigError::InvalidVersionReq(dependency_name.to_string()));
492                }
493                debug!(url:% = url; "using custom url");
494                match url {
495                    UrlType::Git(url) => GitDependency {
496                        name: dependency_name.to_string(),
497                        version_req: dependency_version_req.to_string(),
498                        git: url,
499                        identifier,
500                        project_root: None,
501                    }
502                    .into(),
503                    UrlType::Http(url) => HttpDependency {
504                        name: dependency_name.to_string(),
505                        version_req: dependency_version_req.to_string(),
506                        url: Some(url),
507                        project_root: None,
508                    }
509                    .into(),
510                }
511            }
512            None => HttpDependency {
513                name: dependency_name.to_string(),
514                version_req: dependency_version_req.to_string(),
515                url: None,
516                project_root: None,
517            }
518            .into(),
519        })
520    }
521
522    /// Get the name of the dependency.
523    pub fn name(&self) -> &str {
524        match self {
525            Self::Http(dep) => &dep.name,
526            Self::Git(dep) => &dep.name,
527        }
528    }
529
530    /// Get the version requirement string of the dependency.
531    pub fn version_req(&self) -> &str {
532        match self {
533            Self::Http(dep) => &dep.version_req,
534            Self::Git(dep) => &dep.version_req,
535        }
536    }
537
538    /// Get the URL of the dependency.
539    pub fn url(&self) -> Option<&String> {
540        match self {
541            Self::Http(dep) => dep.url.as_ref(),
542            Self::Git(dep) => Some(&dep.git),
543        }
544    }
545
546    /// Get the install path of the dependency (must exist already).
547    pub fn install_path_sync(&self, deps: impl AsRef<Path>) -> Option<PathBuf> {
548        debug!(dep:% = self; "trying to find installation path of dependency (sync)");
549        find_install_path_sync(self, deps)
550    }
551
552    /// Get the install path of the dependency in an async way (must exist already).
553    pub async fn install_path(&self, deps: impl AsRef<Path>) -> Option<PathBuf> {
554        debug!(dep:% = self; "trying to find installation path of dependency (async)");
555        find_install_path(self, deps).await
556    }
557
558    /// Get the relative path to the project root (config file location).
559    pub fn project_root(&self) -> Option<PathBuf> {
560        match self {
561            Self::Http(dep) => dep.project_root.clone(),
562            Self::Git(dep) => dep.project_root.clone(),
563        }
564    }
565
566    /// Convert the dependency to a TOML value for saving to the config file.
567    pub fn to_toml_value(&self) -> (String, Item) {
568        match self {
569            Self::Http(dep) => (
570                dep.name.clone(),
571                match &dep.url {
572                    Some(url) => {
573                        let mut table = InlineTable::new();
574                        table.insert(
575                            "version",
576                            value(&dep.version_req)
577                                .into_value()
578                                .expect("version should be a valid toml value"),
579                        );
580                        table.insert(
581                            "url",
582                            value(url).into_value().expect("url should be a valid toml value"),
583                        );
584                        if let Some(path) = dep.project_root.as_ref() {
585                            table.insert(
586                                "project_root",
587                                value(path.to_string_lossy().into_owned())
588                                    .into_value()
589                                    .expect("project_root should be a valid toml value"),
590                            );
591                        }
592                        value(table)
593                    }
594                    None => value(&dep.version_req),
595                },
596            ),
597            Self::Git(dep) => {
598                let mut table = InlineTable::new();
599                table.insert(
600                    "version",
601                    value(&dep.version_req)
602                        .into_value()
603                        .expect("version should be a valid toml value"),
604                );
605                table.insert(
606                    "git",
607                    value(&dep.git).into_value().expect("git URL should be a valid toml value"),
608                );
609                match &dep.identifier {
610                    Some(GitIdentifier::Rev(rev)) => {
611                        table.insert(
612                            "rev",
613                            value(rev).into_value().expect("rev should be a valid toml value"),
614                        );
615                    }
616                    Some(GitIdentifier::Branch(branch)) => {
617                        table.insert(
618                            "branch",
619                            value(branch)
620                                .into_value()
621                                .expect("branch should be a valid toml value"),
622                        );
623                    }
624                    Some(GitIdentifier::Tag(tag)) => {
625                        table.insert(
626                            "tag",
627                            value(tag).into_value().expect("tag should be a valid toml value"),
628                        );
629                    }
630                    None => {}
631                }
632                if let Some(path) = dep.project_root.as_ref() {
633                    table.insert(
634                        "project_root",
635                        value(path.to_string_lossy().into_owned())
636                            .into_value()
637                            .expect("project_root should be a valid toml value"),
638                    );
639                }
640                (dep.name.clone(), value(table))
641            }
642        }
643    }
644
645    /// Check if the dependency is an HTTP dependency.
646    pub fn is_http(&self) -> bool {
647        matches!(self, Self::Http(_))
648    }
649
650    /// Cast to a HTTP dependency if it is one.
651    pub fn as_http(&self) -> Option<&HttpDependency> {
652        if let Self::Http(v) = self { Some(v) } else { None }
653    }
654
655    /// Cast to a mutable HTTP dependency if it is one.
656    pub fn as_http_mut(&mut self) -> Option<&mut HttpDependency> {
657        if let Self::Http(v) = self { Some(v) } else { None }
658    }
659
660    /// Check if the dependency is a git dependency.
661    pub fn is_git(&self) -> bool {
662        matches!(self, Self::Git(_))
663    }
664
665    /// Cast to a git dependency if it is one.
666    pub fn as_git(&self) -> Option<&GitDependency> {
667        if let Self::Git(v) = self { Some(v) } else { None }
668    }
669
670    /// Cast to a mutable git dependency if it is one.
671    pub fn as_git_mut(&mut self) -> Option<&mut GitDependency> {
672        if let Self::Git(v) = self { Some(v) } else { None }
673    }
674}
675
676impl From<&HttpDependency> for Dependency {
677    fn from(dep: &HttpDependency) -> Self {
678        Self::Http(dep.clone())
679    }
680}
681
682impl From<&GitDependency> for Dependency {
683    fn from(dep: &GitDependency) -> Self {
684        Self::Git(dep.clone())
685    }
686}
687
688/// The location where the Soldeer config should be stored.
689#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, FromStr)]
690#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))]
691pub enum ConfigLocation {
692    /// The `foundry.toml` file.
693    Foundry,
694
695    /// The `soldeer.toml` file.
696    Soldeer,
697}
698
699impl From<ConfigLocation> for PathBuf {
700    fn from(value: ConfigLocation) -> Self {
701        match value {
702            ConfigLocation::Foundry => Paths::foundry_default(),
703            ConfigLocation::Soldeer => Paths::soldeer_default(),
704        }
705    }
706}
707
708/// A warning generated during parsing of a dependency from the config file.
709#[derive(Debug, Clone, PartialEq, Eq, Hash)]
710#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))]
711pub struct ParsingWarning {
712    dependency_name: String,
713    message: String,
714}
715
716impl fmt::Display for ParsingWarning {
717    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
718        write!(f, "{}: {}", self.dependency_name, self.message)
719    }
720}
721
722/// The result of parsing a dependency from the config file.
723#[derive(Debug, Clone, PartialEq, Eq, Hash)]
724#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))]
725pub struct ParsingResult {
726    pub dependency: Dependency,
727    pub warnings: Vec<ParsingWarning>,
728}
729
730impl ParsingResult {
731    /// Whether the parsing result contains one or more warnings.
732    pub fn has_warnings(&self) -> bool {
733        !self.warnings.is_empty()
734    }
735}
736
737impl From<HttpDependency> for ParsingResult {
738    fn from(value: HttpDependency) -> Self {
739        Self { dependency: value.into(), warnings: Vec::default() }
740    }
741}
742
743impl From<GitDependency> for ParsingResult {
744    fn from(value: GitDependency) -> Self {
745        Self { dependency: value.into(), warnings: Vec::default() }
746    }
747}
748
749impl From<Dependency> for ParsingResult {
750    fn from(value: Dependency) -> Self {
751        Self { dependency: value, warnings: Vec::default() }
752    }
753}
754
755/// Detect the location of the config file in case no user preference is available.
756///
757/// The function will try to auto-detect the location based on the existence of the
758/// `dependencies` entry in the foundry config file, or the existence of a `soldeer.toml` file.
759/// If no config can be found, `None` is returned.
760pub fn detect_config_location(root: impl AsRef<Path>) -> Option<ConfigLocation> {
761    let foundry_path = root.as_ref().join("foundry.toml");
762    let soldeer_path = root.as_ref().join("soldeer.toml");
763    if let Ok(contents) = fs::read_to_string(&foundry_path) {
764        debug!(path:? = foundry_path; "found foundry.toml file");
765        if let Ok(doc) = contents.parse::<DocumentMut>() {
766            if doc.contains_table("dependencies") {
767                debug!("found `dependencies` table in foundry.toml, so using that file for config");
768                return Some(ConfigLocation::Foundry);
769            } else {
770                debug!("foundry.toml does not contain `dependencies`, trying to use soldeer.toml");
771            }
772        } else {
773            warn!(path:? = foundry_path; "foundry.toml could not be parsed a toml");
774        }
775    } else if soldeer_path.exists() {
776        debug!(path:? = soldeer_path; "soldeer.toml exists, using that file for config");
777        return Some(ConfigLocation::Soldeer);
778    }
779    debug!("could not determine existing config file location");
780    None
781}
782
783/// Read the list of dependencies from the config file.
784///
785/// Dependencies are stored in a TOML table under the `dependencies` key.
786/// Each key inside of the table is the name of the dependency and the value can be:
787/// - a string representing the version requirement
788/// - a table with the following fields:
789///   - `version` (required): the version requirement string
790///   - `url` (optional): the URL to the dependency's zip file
791///   - `git` (optional): the git URL for git dependencies
792///   - `rev` (optional): the revision hash for git dependencies
793///   - `branch` (optional): the branch name for git dependencies
794///   - `tag` (optional): the tag name for git dependencies
795///   - `project_root` (optional): relative path to the folder containing the config file
796pub fn read_config_deps(path: impl AsRef<Path>) -> Result<(Vec<Dependency>, Vec<ParsingWarning>)> {
797    let contents = fs::read_to_string(&path)?;
798    let doc: DocumentMut = contents.parse::<DocumentMut>()?;
799    let Some(Some(data)) = doc.get("dependencies").map(|v| v.as_table()) else {
800        warn!("no `dependencies` table in config file");
801        return Ok(Default::default());
802    };
803
804    let mut dependencies: Vec<Dependency> = Vec::new();
805    let mut warnings: Vec<ParsingWarning> = Vec::new();
806    for (name, v) in data {
807        let mut res = parse_dependency(name, v)?;
808        dependencies.push(res.dependency);
809        warnings.append(&mut res.warnings);
810    }
811    debug!(path:? = path.as_ref(); "found {} dependencies in config file", dependencies.len());
812    Ok((dependencies, warnings))
813}
814
815/// Read the Soldeer config from the config file.
816pub fn read_soldeer_config(path: impl AsRef<Path>) -> Result<SoldeerConfig> {
817    #[derive(Deserialize)]
818    struct SoldeerConfigParsed {
819        #[serde(default)]
820        soldeer: SoldeerConfig,
821    }
822
823    let contents = fs::read_to_string(&path)?;
824
825    let config: SoldeerConfigParsed = toml_edit::de::from_str(&contents)?;
826
827    debug!(path:? = path.as_ref(); "parsed soldeer config from file");
828    Ok(config.soldeer)
829}
830
831/// Add a dependency to the config file.
832pub fn add_to_config(dependency: &Dependency, config_path: impl AsRef<Path>) -> Result<()> {
833    let contents = fs::read_to_string(&config_path)?;
834    let mut doc: DocumentMut = contents.parse::<DocumentMut>()?;
835
836    // in case we don't have the dependencies section defined in the config file, we add it
837    if !doc.contains_table("dependencies") {
838        debug!("`dependencies` table added to config file because it was missing");
839        doc.insert("dependencies", Item::Table(Table::default()));
840    }
841
842    let (name, value) = dependency.to_toml_value();
843    doc["dependencies"]
844        .as_table_mut()
845        .expect("dependencies should be a table")
846        .insert(&name, value);
847
848    fs::write(&config_path, doc.to_string())?;
849    debug!(dep:% = dependency, path:? = config_path.as_ref(); "added dependency to config file");
850    Ok(())
851}
852
853/// Delete a dependency from the config file.
854pub fn delete_from_config(dependency_name: &str, path: impl AsRef<Path>) -> Result<Dependency> {
855    let contents = fs::read_to_string(&path)?;
856    let mut doc: DocumentMut = contents.parse::<DocumentMut>().expect("invalid doc");
857
858    let Some(dependencies) = doc["dependencies"].as_table_mut() else {
859        debug!("no `dependencies` table in config file");
860        return Err(ConfigError::MissingDependency(dependency_name.to_string()));
861    };
862    let Some(item_removed) = dependencies.remove(dependency_name) else {
863        debug!("dependency not present in config file");
864        return Err(ConfigError::MissingDependency(dependency_name.to_string()));
865    };
866
867    let dependency = parse_dependency(dependency_name, &item_removed)?;
868
869    fs::write(&path, doc.to_string())?;
870    debug!(dep = dependency_name, path:? = path.as_ref(); "removed dependency from config file");
871    Ok(dependency.dependency)
872}
873
874/// Update the config file to add the `dependencies` folder as a source for libraries and the
875/// `[dependencies]` table if necessary.
876pub fn update_config_libs(foundry_config: impl AsRef<Path>) -> Result<()> {
877    let contents = fs::read_to_string(&foundry_config)?;
878    let mut doc: DocumentMut = contents.parse::<DocumentMut>()?;
879
880    if !doc.contains_key("profile") {
881        debug!("missing `profile` in config file, adding it");
882        let mut profile = Table::default();
883        profile["default"] = Item::Table(Table::default());
884        profile.set_implicit(true);
885        doc["profile"] = Item::Table(profile);
886    }
887
888    let profile = doc["profile"].as_table_mut().expect("profile should be a table");
889    if !profile.contains_key("default") {
890        debug!("missing `default` profile in config file, adding it");
891        profile["default"] = Item::Table(Table::default());
892    }
893
894    let default_profile =
895        profile["default"].as_table_mut().expect("default profile should be a table");
896    if !default_profile.contains_key("libs") {
897        debug!("missing `libs` array in config file, adding it");
898        default_profile["libs"] = value(Array::from_iter(&["dependencies".to_string()]));
899    }
900
901    let libs = default_profile["libs"].as_array_mut().expect("libs should be an array");
902    if !libs.iter().any(|v| v.as_str() == Some("dependencies")) {
903        debug!("adding `dependencies` folder to `libs` array");
904        libs.push("dependencies");
905    }
906
907    // in case we don't have the dependencies section defined in the config file, we add it
908    if !doc.contains_table("dependencies") {
909        debug!("adding `dependencies` table in config file");
910        doc.insert("dependencies", Item::Table(Table::default()));
911    }
912
913    fs::write(&foundry_config, doc.to_string())?;
914    debug!(path:? = foundry_config.as_ref(); "config file updated");
915    Ok(())
916}
917
918/// Find the top-level directory of the working git tree.
919///
920/// If no `.git` folder is found in the ancestors, `None` is returned.
921fn find_git_root(relative_to: impl AsRef<Path>) -> Result<Option<PathBuf>> {
922    let root = dunce::canonicalize(relative_to)?;
923    Ok(root.ancestors().find(|p| p.join(".git").is_dir()).map(Path::to_path_buf))
924}
925
926/// Find the root of the project at the current directory or path specified by `cwd`.
927///
928/// Looks for a file named `foundry.toml` or `soldeer.toml` in the ancestors of the optional path
929/// passed as argument. If `None` is given, then the current directory is retrieved from the
930/// environment and used as the start point for the search.
931///
932/// The search is bounded by the root of the working git tree, so as to avoid false positives for
933/// nested dependencies. If no config file is found, but a `.git` folder is found, then the
934/// top-level directory of the working git tree will be returned. If the git root cannot be found,
935/// then the start point of the search is returned (current dir or given path).
936///
937/// This function is not meant to be used directly, instead use [`Paths::get_root_path`] which
938/// honors environment variables.
939fn find_project_root(cwd: Option<impl AsRef<Path>>) -> Result<PathBuf> {
940    let cwd = match cwd {
941        Some(path) => dunce::canonicalize(path)?,
942        None => env::current_dir()?,
943    };
944    let boundary = find_git_root(&cwd)?;
945    let found = cwd
946        .ancestors()
947        .take_while(|p| boundary.as_ref().map(|b| p.starts_with(b)).unwrap_or(true))
948        .find(|p| p.join("foundry.toml").is_file() || p.join("soldeer.toml").is_file())
949        .map(Path::to_path_buf);
950    Ok(found.or(boundary).unwrap_or_else(|| cwd.to_path_buf()))
951}
952
953/// Parse a dependency from a TOML value.
954///
955/// The value can be a string (version requirement) or a table.
956/// The table can have the following fields:
957/// - `version` (required): the version requirement string
958/// - `url` (optional): the URL to the dependency's zip file
959/// - `git` (optional): the git URL for git dependencies
960/// - `rev` (optional): the revision hash for git dependencies
961/// - `branch` (optional): the branch name for git dependencies
962/// - `tag` (optional): the tag name for git dependencies
963/// - `project_root` (optional): relative path to the folder containing the config file
964///
965/// Note that the version requirement string cannot contain the `=` symbol for git dependencies
966/// and HTTP dependencies with a custom URL.
967fn parse_dependency(name: impl Into<String>, value: &Item) -> Result<ParsingResult> {
968    let name: String = name.into();
969    if let Some(version_req) = value.as_str() {
970        if version_req.is_empty() {
971            return Err(ConfigError::EmptyVersion(name));
972        }
973        // this function does not retrieve the url
974        return Ok(HttpDependency {
975            name,
976            version_req: version_req.to_string(),
977            url: None,
978            project_root: None,
979        }
980        .into());
981    }
982
983    // we should have a table or inline table
984    let table = {
985        match value.as_inline_table() {
986            Some(table) => table,
987            None => match value.as_table() {
988                // we normalize to inline table
989                Some(table) => &table.clone().into_inline_table(),
990                None => {
991                    debug!(dep = name; "dependency config entry could not be parsed as a table");
992                    return Err(ConfigError::InvalidDependency(name));
993                }
994            },
995        }
996    };
997
998    let mut warnings = Vec::new();
999
1000    // check for unsupported fields
1001    warnings.extend(table.iter().filter_map(|(k, _)| {
1002        if !["version", "url", "git", "rev", "branch", "tag", "project_root"].contains(&k) {
1003            warn!(dependency = name; "toml parsing: `{k}` is not a valid dependency option");
1004            Some(ParsingWarning {
1005                dependency_name: name.clone(),
1006                message: format!("`{k}` is not a valid dependency option"),
1007            })
1008        } else {
1009            None
1010        }
1011    }));
1012
1013    // version is needed in both cases
1014    let version_req = match table.get("version").map(|v| v.as_str()) {
1015        Some(None) => {
1016            debug!(dep = name; "dependency's `version` field is not a string");
1017            return Err(ConfigError::InvalidField { field: "version".to_string(), dep: name });
1018        }
1019        None => {
1020            return Err(ConfigError::MissingField { field: "version".to_string(), dep: name });
1021        }
1022        Some(Some(version_req)) => version_req.to_string(),
1023    };
1024    if version_req.is_empty() {
1025        return Err(ConfigError::EmptyVersion(name));
1026    }
1027
1028    // both types of dependency definition can have the `project_root` field.
1029    let project_root = match table.get("project_root").map(|v| v.as_str()) {
1030        Some(Some(path)) => Some(path.into()),
1031        Some(None) => {
1032            debug!(dep = name; "dependency's `project_root` field is not a string");
1033            return Err(ConfigError::InvalidField { field: "project_root".to_string(), dep: name });
1034        }
1035        None => None,
1036    };
1037
1038    // check if it's a git dependency
1039    match table.get("git").map(|v| v.as_str()) {
1040        Some(None) => {
1041            debug!(dep = name; "dependency's `git` field is not a string");
1042            return Err(ConfigError::InvalidField { field: "git".to_string(), dep: name });
1043        }
1044        Some(Some(git)) => {
1045            // we can't have an http url if we have a git url
1046            if table.get("url").is_some() {
1047                return Err(ConfigError::FieldConflict {
1048                    field: "url".to_string(),
1049                    conflicts_with: "git".to_string(),
1050                    dep: name,
1051                });
1052            }
1053
1054            // for git dependencies, the version requirement string is going to be used as part of
1055            // the folder name inside the dependencies folder. As such, it's not allowed to contain
1056            // the "=" character, because that would break the remappings.
1057            if version_req.contains('=') {
1058                return Err(ConfigError::InvalidVersionReq(name));
1059            }
1060            // rev/branch/tag fields are optional but need to be a string if present
1061            let rev = match table.get("rev").map(|v| v.as_str()) {
1062                Some(Some(rev)) => Some(rev.to_string()),
1063                Some(None) => {
1064                    debug!(dep = name; "dependency's `rev` field is not a string");
1065                    return Err(ConfigError::InvalidField { field: "rev".to_string(), dep: name });
1066                }
1067                None => None,
1068            };
1069            let branch = match table.get("branch").map(|v| v.as_str()) {
1070                Some(Some(tag)) => Some(tag.to_string()),
1071                Some(None) => {
1072                    debug!(dep = name; "dependency's `branch` field is not a string");
1073                    return Err(ConfigError::InvalidField {
1074                        field: "branch".to_string(),
1075                        dep: name,
1076                    });
1077                }
1078                None => None,
1079            };
1080            let tag = match table.get("tag").map(|v| v.as_str()) {
1081                Some(Some(tag)) => Some(tag.to_string()),
1082                Some(None) => {
1083                    debug!(dep = name; "dependency's `tag` field is not a string");
1084                    return Err(ConfigError::InvalidField { field: "tag".to_string(), dep: name });
1085                }
1086                None => None,
1087            };
1088            let identifier = match (rev, branch, tag) {
1089                (Some(rev), None, None) => Some(GitIdentifier::from_rev(rev)),
1090                (None, Some(branch), None) => Some(GitIdentifier::from_branch(branch)),
1091                (None, None, Some(tag)) => Some(GitIdentifier::from_tag(tag)),
1092                (None, None, None) => None,
1093                _ => {
1094                    return Err(ConfigError::GitIdentifierConflict(name));
1095                }
1096            };
1097            return Ok(ParsingResult {
1098                dependency: GitDependency {
1099                    name,
1100                    git: git.to_string(),
1101                    version_req,
1102                    identifier,
1103                    project_root,
1104                }
1105                .into(),
1106                warnings,
1107            });
1108        }
1109        None => {}
1110    }
1111
1112    // we should have a HTTP dependency,
1113
1114    // check for extra fields in the HTTP context
1115    warnings.extend(table.iter().filter_map(|(k, _)| {
1116        if ["rev", "branch", "tag"].contains(&k) {
1117            warn!(dependency = name; "toml parsing: `{k}` is ignored if no `git` URL is provided");
1118            Some(ParsingWarning {
1119                dependency_name: name.clone(),
1120                message: format!("`{k}` is ignored if no `git` URL is provided"),
1121            })
1122        } else {
1123            None
1124        }
1125    }));
1126
1127    match table.get("url").map(|v| v.as_str()) {
1128        Some(None) => {
1129            debug!(dep = name; "dependency's `url` field is not a string");
1130            Err(ConfigError::InvalidField { field: "url".to_string(), dep: name })
1131        }
1132        None => Ok(ParsingResult {
1133            dependency: HttpDependency { name, version_req, url: None, project_root }.into(),
1134            warnings,
1135        }),
1136        Some(Some(url)) => {
1137            // for HTTP dependencies with custom URL, the version requirement string is going to be
1138            // used as part of the folder name inside the dependencies folder. As such,
1139            // it's not allowed to contain the "=" character, because that would break
1140            // the remappings.
1141            if version_req.contains('=') {
1142                return Err(ConfigError::InvalidVersionReq(name));
1143            }
1144            Ok(ParsingResult {
1145                dependency: HttpDependency {
1146                    name,
1147                    version_req,
1148                    url: Some(url.to_string()),
1149                    project_root,
1150                }
1151                .into(),
1152                warnings,
1153            })
1154        }
1155    }
1156}
1157
1158/// Create a basic config file with default contents if it doesn't exist, otherwise add
1159/// `[dependencies]` if necessary.
1160fn create_or_modify_config(
1161    location: ConfigLocation,
1162    foundry_path: impl AsRef<Path>,
1163    soldeer_path: impl AsRef<Path>,
1164) -> Result<PathBuf> {
1165    match location {
1166        ConfigLocation::Foundry => {
1167            let foundry_path = foundry_path.as_ref();
1168            if foundry_path.exists() {
1169                update_config_libs(foundry_path)?;
1170                return Ok(foundry_path.to_path_buf());
1171            }
1172            debug!(path:? = foundry_path; "foundry.toml does not exist, creating it");
1173            let contents = r#"[profile.default]
1174src = "src"
1175out = "out"
1176libs = ["dependencies"]
1177
1178[dependencies]
1179
1180# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options
1181"#;
1182
1183            fs::write(foundry_path, contents)?;
1184            Ok(foundry_path.to_path_buf())
1185        }
1186        ConfigLocation::Soldeer => {
1187            let soldeer_path = soldeer_path.as_ref();
1188            if soldeer_path.exists() {
1189                return Ok(soldeer_path.to_path_buf());
1190            }
1191            debug!(path:? = soldeer_path; "soldeer.toml does not exist, creating it");
1192            fs::write(soldeer_path, "[dependencies]\n")?;
1193            Ok(soldeer_path.to_path_buf())
1194        }
1195    }
1196}
1197
1198#[cfg(test)]
1199mod tests {
1200    use super::*;
1201    use crate::errors::ConfigError;
1202    use path_slash::PathBufExt;
1203    use std::{fs, path::PathBuf};
1204    use temp_env::with_var;
1205    use testdir::testdir;
1206
1207    fn write_to_config(content: &str, filename: &str) -> PathBuf {
1208        let path = testdir!().join(filename);
1209        fs::write(&path, content).unwrap();
1210        path
1211    }
1212
1213    #[test]
1214    fn test_paths_config_soldeer() {
1215        let config_path = write_to_config("[dependencies]\n", "soldeer.toml");
1216        with_var(
1217            "SOLDEER_PROJECT_ROOT",
1218            Some(config_path.parent().unwrap().to_string_lossy().to_string()),
1219            || {
1220                let res = Paths::new();
1221                assert!(res.is_ok(), "{res:?}");
1222                assert_eq!(res.unwrap().config.to_slash_lossy(), config_path.to_slash_lossy());
1223            },
1224        );
1225    }
1226
1227    #[test]
1228    fn test_paths_config_foundry() {
1229        let config_contents = r#"[profile.default]
1230libs = ["dependencies"]
1231
1232[dependencies]
1233"#;
1234        let config_path = write_to_config(config_contents, "foundry.toml");
1235        with_var(
1236            "SOLDEER_PROJECT_ROOT",
1237            Some(config_path.parent().unwrap().to_string_lossy().to_string()),
1238            || {
1239                let res = Paths::new();
1240                assert!(res.is_ok(), "{res:?}");
1241                assert_eq!(res.unwrap().config, config_path);
1242            },
1243        );
1244    }
1245
1246    #[test]
1247    fn test_paths_from_root() {
1248        let config_path = write_to_config("[dependencies]\n", "soldeer.toml");
1249        let root = config_path.parent().unwrap();
1250        let res = Paths::from_root(root);
1251        assert!(res.is_ok(), "{res:?}");
1252        assert_eq!(res.unwrap().root, root);
1253    }
1254
1255    #[test]
1256    fn test_from_name_version_no_url() {
1257        let res = Dependency::from_name_version("dependency~1.0.0", None, None);
1258        assert!(res.is_ok(), "{res:?}");
1259        assert_eq!(
1260            res.unwrap(),
1261            HttpDependency::builder().name("dependency").version_req("1.0.0").build().into()
1262        );
1263    }
1264
1265    #[test]
1266    fn test_from_name_version_with_http_url() {
1267        let res = Dependency::from_name_version(
1268            "dependency~1.0.0",
1269            Some(UrlType::http("https://github.com/user/repo/archive/123.zip")),
1270            None,
1271        );
1272        assert!(res.is_ok(), "{res:?}");
1273        assert_eq!(
1274            res.unwrap(),
1275            HttpDependency::builder()
1276                .name("dependency")
1277                .version_req("1.0.0")
1278                .url("https://github.com/user/repo/archive/123.zip")
1279                .build()
1280                .into()
1281        );
1282    }
1283
1284    #[test]
1285    fn test_from_name_version_with_git_url() {
1286        let res = Dependency::from_name_version(
1287            "dependency~1.0.0",
1288            Some(UrlType::git("https://github.com/user/repo.git")),
1289            None,
1290        );
1291        assert!(res.is_ok(), "{res:?}");
1292        assert_eq!(
1293            res.unwrap(),
1294            GitDependency::builder()
1295                .name("dependency")
1296                .version_req("1.0.0")
1297                .git("https://github.com/user/repo.git")
1298                .build()
1299                .into()
1300        );
1301
1302        let res = Dependency::from_name_version(
1303            "dependency~1.0.0",
1304            Some(UrlType::git("https://test:test@gitlab.com/user/repo.git")),
1305            None,
1306        );
1307        assert!(res.is_ok(), "{res:?}");
1308        assert_eq!(
1309            res.unwrap(),
1310            GitDependency::builder()
1311                .name("dependency")
1312                .version_req("1.0.0")
1313                .git("https://test:test@gitlab.com/user/repo.git")
1314                .build()
1315                .into()
1316        );
1317    }
1318
1319    #[test]
1320    fn test_from_name_version_with_git_url_rev() {
1321        let res = Dependency::from_name_version(
1322            "dependency~1.0.0",
1323            Some(UrlType::git("https://github.com/user/repo.git")),
1324            Some(GitIdentifier::from_rev("123456")),
1325        );
1326        assert!(res.is_ok(), "{res:?}");
1327        assert_eq!(
1328            res.unwrap(),
1329            GitDependency::builder()
1330                .name("dependency")
1331                .version_req("1.0.0")
1332                .git("https://github.com/user/repo.git")
1333                .identifier(GitIdentifier::from_rev("123456"))
1334                .build()
1335                .into()
1336        );
1337    }
1338
1339    #[test]
1340    fn test_from_name_version_with_git_url_branch() {
1341        let res = Dependency::from_name_version(
1342            "dependency~1.0.0",
1343            Some(UrlType::git("https://github.com/user/repo.git")),
1344            Some(GitIdentifier::from_branch("dev")),
1345        );
1346        assert!(res.is_ok(), "{res:?}");
1347        assert_eq!(
1348            res.unwrap(),
1349            GitDependency::builder()
1350                .name("dependency")
1351                .version_req("1.0.0")
1352                .git("https://github.com/user/repo.git")
1353                .identifier(GitIdentifier::from_branch("dev"))
1354                .build()
1355                .into()
1356        );
1357    }
1358
1359    #[test]
1360    fn test_from_name_version_with_git_url_tag() {
1361        let res = Dependency::from_name_version(
1362            "dependency~1.0.0",
1363            Some(UrlType::git("https://github.com/user/repo.git")),
1364            Some(GitIdentifier::from_tag("v1.0.0")),
1365        );
1366        assert!(res.is_ok(), "{res:?}");
1367        assert_eq!(
1368            res.unwrap(),
1369            GitDependency::builder()
1370                .name("dependency")
1371                .version_req("1.0.0")
1372                .git("https://github.com/user/repo.git")
1373                .identifier(GitIdentifier::from_tag("v1.0.0"))
1374                .build()
1375                .into()
1376        );
1377    }
1378
1379    #[test]
1380    fn test_from_name_version_with_git_ssh() {
1381        let res = Dependency::from_name_version(
1382            "dependency~1.0.0",
1383            Some(UrlType::git("git@github.com:user/repo.git")),
1384            None,
1385        );
1386        assert!(res.is_ok(), "{res:?}");
1387        assert_eq!(
1388            res.unwrap(),
1389            GitDependency::builder()
1390                .name("dependency")
1391                .version_req("1.0.0")
1392                .git("git@github.com:user/repo.git")
1393                .build()
1394                .into()
1395        );
1396    }
1397
1398    #[test]
1399    fn test_from_name_version_with_git_ssh_rev() {
1400        let res = Dependency::from_name_version(
1401            "dependency~1.0.0",
1402            Some(UrlType::git("git@github.com:user/repo.git")),
1403            Some(GitIdentifier::from_rev("123456")),
1404        );
1405        assert!(res.is_ok(), "{res:?}");
1406        assert_eq!(
1407            res.unwrap(),
1408            GitDependency::builder()
1409                .name("dependency")
1410                .version_req("1.0.0")
1411                .git("git@github.com:user/repo.git")
1412                .identifier(GitIdentifier::from_rev("123456"))
1413                .build()
1414                .into()
1415        );
1416    }
1417
1418    #[test]
1419    fn test_from_name_version_empty_version() {
1420        let res = Dependency::from_name_version("dependency~", None, None);
1421        assert!(matches!(res, Err(ConfigError::EmptyVersion(_))), "{res:?}");
1422    }
1423
1424    #[test]
1425    fn test_from_name_version_invalid_version() {
1426        // for http deps, having the "=" character in the version requirement is ok
1427        let res = Dependency::from_name_version("dependency~asdf=", None, None);
1428        assert!(res.is_ok(), "{res:?}");
1429
1430        let res = Dependency::from_name_version(
1431            "dependency~asdf=",
1432            Some(UrlType::http("https://example.com")),
1433            None,
1434        );
1435        assert!(matches!(res, Err(ConfigError::InvalidVersionReq(_))), "{res:?}");
1436
1437        let res = Dependency::from_name_version(
1438            "dependency~asdf=",
1439            Some(UrlType::git("git@github.com:user/repo.git")),
1440            None,
1441        );
1442        assert!(matches!(res, Err(ConfigError::InvalidVersionReq(_))), "{res:?}");
1443    }
1444
1445    #[test]
1446    fn test_read_soldeer_config_default() {
1447        let config_contents = r#"[profile.default]
1448libs = ["dependencies"]
1449"#;
1450        let config_path = write_to_config(config_contents, "foundry.toml");
1451        let res = read_soldeer_config(config_path);
1452        assert!(res.is_ok(), "{res:?}");
1453        assert_eq!(res.unwrap(), SoldeerConfig::default());
1454    }
1455
1456    #[test]
1457    fn test_read_soldeer_config() {
1458        let config_contents = r#"[soldeer]
1459remappings_generate = false
1460remappings_regenerate = true
1461remappings_version = false
1462remappings_prefix = "@"
1463remappings_location = "config"
1464recursive_deps = true
1465"#;
1466        let expected = SoldeerConfig {
1467            remappings_generate: false,
1468            remappings_regenerate: true,
1469            remappings_version: false,
1470            remappings_prefix: "@".to_string(),
1471            remappings_location: RemappingsLocation::Config,
1472            recursive_deps: true,
1473        };
1474
1475        let config_path = write_to_config(config_contents, "soldeer.toml");
1476        let res = read_soldeer_config(config_path);
1477        assert!(res.is_ok(), "{res:?}");
1478        assert_eq!(res.unwrap(), expected);
1479
1480        let config_path = write_to_config(config_contents, "foundry.toml");
1481        let res = read_soldeer_config(config_path);
1482        assert!(res.is_ok(), "{res:?}");
1483        assert_eq!(res.unwrap(), expected);
1484    }
1485
1486    #[test]
1487    fn test_read_foundry_config_deps() {
1488        let config_contents = r#"[profile.default]
1489libs = ["dependencies"]
1490
1491[dependencies]
1492"lib1" = "1.0.0"
1493"lib2" = { version = "2.0.0" }
1494"lib3" = { version = "3.0.0", url = "https://example.com" }
1495"lib4" = { version = "4.0.0", git = "https://example.com/repo.git" }
1496"lib5" = { version = "5.0.0", git = "https://example.com/repo.git", rev = "123456" }
1497"lib6" = { version = "6.0.0", git = "https://example.com/repo.git", branch = "dev" }
1498"lib7" = { version = "7.0.0", git = "https://example.com/repo.git", tag = "v7.0.0" }
1499"lib8" = { version = "8.0.0", url = "https://example.com", project_root = "foo/bar" }
1500"lib9" = { version = "9.0.0", git = "https://example.com/repo.git", project_root = "test/test2" }
1501"#;
1502        let config_path = write_to_config(config_contents, "foundry.toml");
1503        let res = read_config_deps(config_path);
1504        assert!(res.is_ok(), "{res:?}");
1505        let (result, _) = res.unwrap();
1506
1507        assert_eq!(
1508            result[0],
1509            HttpDependency::builder().name("lib1").version_req("1.0.0").build().into()
1510        );
1511        assert_eq!(
1512            result[1],
1513            HttpDependency::builder().name("lib2").version_req("2.0.0").build().into()
1514        );
1515        assert_eq!(
1516            result[2],
1517            HttpDependency::builder()
1518                .name("lib3")
1519                .version_req("3.0.0")
1520                .url("https://example.com")
1521                .build()
1522                .into()
1523        );
1524        assert_eq!(
1525            result[3],
1526            GitDependency::builder()
1527                .name("lib4")
1528                .version_req("4.0.0")
1529                .git("https://example.com/repo.git")
1530                .build()
1531                .into()
1532        );
1533        assert_eq!(
1534            result[4],
1535            GitDependency::builder()
1536                .name("lib5")
1537                .version_req("5.0.0")
1538                .git("https://example.com/repo.git")
1539                .identifier(GitIdentifier::from_rev("123456"))
1540                .build()
1541                .into()
1542        );
1543        assert_eq!(
1544            result[5],
1545            GitDependency::builder()
1546                .name("lib6")
1547                .version_req("6.0.0")
1548                .git("https://example.com/repo.git")
1549                .identifier(GitIdentifier::from_branch("dev"))
1550                .build()
1551                .into()
1552        );
1553        assert_eq!(
1554            result[6],
1555            GitDependency::builder()
1556                .name("lib7")
1557                .version_req("7.0.0")
1558                .git("https://example.com/repo.git")
1559                .identifier(GitIdentifier::from_tag("v7.0.0"))
1560                .build()
1561                .into()
1562        );
1563        assert_eq!(
1564            result[7],
1565            HttpDependency::builder()
1566                .name("lib8")
1567                .version_req("8.0.0")
1568                .url("https://example.com")
1569                .project_root("foo/bar")
1570                .build()
1571                .into()
1572        );
1573        assert_eq!(
1574            result[8],
1575            GitDependency::builder()
1576                .name("lib9")
1577                .version_req("9.0.0")
1578                .git("https://example.com/repo.git")
1579                .project_root("test/test2")
1580                .build()
1581                .into()
1582        );
1583    }
1584
1585    #[test]
1586    fn test_read_soldeer_config_deps() {
1587        let config_contents = r#"[dependencies]
1588"lib1" = "1.0.0"
1589"lib2" = { version = "2.0.0" }
1590"lib3" = { version = "3.0.0", url = "https://example.com" }
1591"lib4" = { version = "4.0.0", git = "https://example.com/repo.git" }
1592"lib5" = { version = "5.0.0", git = "https://example.com/repo.git", rev = "123456" }
1593"lib6" = { version = "6.0.0", git = "https://example.com/repo.git", branch = "dev" }
1594"lib7" = { version = "7.0.0", git = "https://example.com/repo.git", tag = "v7.0.0" }
1595"lib8" = { version = "8.0.0", url = "https://example.com", project_root = "foo/bar" }
1596"lib9" = { version = "9.0.0", git = "https://example.com/repo.git", project_root = "test/test2" }
1597"#;
1598        let config_path = write_to_config(config_contents, "soldeer.toml");
1599        let res = read_config_deps(config_path);
1600        assert!(res.is_ok(), "{res:?}");
1601        let (result, _) = res.unwrap();
1602
1603        assert_eq!(
1604            result[0],
1605            HttpDependency::builder().name("lib1").version_req("1.0.0").build().into()
1606        );
1607        assert_eq!(
1608            result[1],
1609            HttpDependency::builder().name("lib2").version_req("2.0.0").build().into()
1610        );
1611        assert_eq!(
1612            result[2],
1613            HttpDependency::builder()
1614                .name("lib3")
1615                .version_req("3.0.0")
1616                .url("https://example.com")
1617                .build()
1618                .into()
1619        );
1620        assert_eq!(
1621            result[3],
1622            GitDependency::builder()
1623                .name("lib4")
1624                .version_req("4.0.0")
1625                .git("https://example.com/repo.git")
1626                .build()
1627                .into()
1628        );
1629        assert_eq!(
1630            result[4],
1631            GitDependency::builder()
1632                .name("lib5")
1633                .version_req("5.0.0")
1634                .git("https://example.com/repo.git")
1635                .identifier(GitIdentifier::from_rev("123456"))
1636                .build()
1637                .into()
1638        );
1639        assert_eq!(
1640            result[5],
1641            GitDependency::builder()
1642                .name("lib6")
1643                .version_req("6.0.0")
1644                .git("https://example.com/repo.git")
1645                .identifier(GitIdentifier::from_branch("dev"))
1646                .build()
1647                .into()
1648        );
1649        assert_eq!(
1650            result[6],
1651            GitDependency::builder()
1652                .name("lib7")
1653                .version_req("7.0.0")
1654                .git("https://example.com/repo.git")
1655                .identifier(GitIdentifier::from_tag("v7.0.0"))
1656                .build()
1657                .into()
1658        );
1659        assert_eq!(
1660            result[7],
1661            HttpDependency::builder()
1662                .name("lib8")
1663                .version_req("8.0.0")
1664                .url("https://example.com")
1665                .project_root("foo/bar")
1666                .build()
1667                .into()
1668        );
1669        assert_eq!(
1670            result[8],
1671            GitDependency::builder()
1672                .name("lib9")
1673                .version_req("9.0.0")
1674                .git("https://example.com/repo.git")
1675                .project_root("test/test2")
1676                .build()
1677                .into()
1678        );
1679    }
1680
1681    #[test]
1682    fn test_read_soldeer_config_deps_bad_version() {
1683        for dep in [
1684            r#""lib1" = """#,
1685            r#""lib1" = { version = "" }"#,
1686            r#""lib1" = { version = "", url = "https://example.com" }"#,
1687            r#""lib1" = { version = "", git = "https://example.com/repo.git" }"#,
1688            r#""lib1" = { version = "", git = "https://example.com/repo.git", rev = "123456" }"#,
1689        ] {
1690            let config_contents = format!("[dependencies]\n{dep}");
1691            let config_path = write_to_config(&config_contents, "soldeer.toml");
1692            let res = read_config_deps(config_path);
1693            assert!(matches!(res, Err(ConfigError::EmptyVersion(_))), "{res:?}");
1694        }
1695
1696        for dep in [
1697            r#""lib1" = { version = "asdf=", url = "https://example.com" }"#,
1698            r#""lib1" = { version = "asdf=", git = "https://example.com/repo.git" }"#,
1699            r#""lib1" = { version = "asdf=", git = "https://example.com/repo.git", rev = "123456" }"#,
1700        ] {
1701            let config_contents = format!("[dependencies]\n{dep}");
1702            let config_path = write_to_config(&config_contents, "soldeer.toml");
1703            let res = read_config_deps(config_path);
1704            assert!(matches!(res, Err(ConfigError::InvalidVersionReq(_))), "{res:?}");
1705        }
1706
1707        // it's ok to have the "=" character in the version requirement for HTTP dependencies
1708        // without a custom URL
1709        let config_contents = r#"[dependencies]
1710"lib1" = "asdf="
1711"lib2" = { version = "asdf=" }
1712"#;
1713        let config_path = write_to_config(config_contents, "soldeer.toml");
1714        let res = read_config_deps(config_path);
1715        assert!(res.is_ok(), "{res:?}");
1716    }
1717
1718    #[test]
1719    fn test_read_soldeer_config_deps_bad_git() {
1720        for dep in [
1721            r#""lib1" = { version = "1.0.0", git = "https://example.com/repo.git", rev = "123456", branch = "dev" }"#,
1722            r#""lib1" = { version = "1.0.0", git = "https://example.com/repo.git", rev = "123456", tag = "v1.0.0" }"#,
1723            r#""lib1" = { version = "1.0.0", git = "https://example.com/repo.git", branch = "dev", tag = "v1.0.0" }"#,
1724            r#""lib1" = { version = "1.0.0", git = "https://example.com/repo.git", rev = "123456", branch = "dev", tag = "v1.0.0" }"#,
1725        ] {
1726            let config_contents = format!("[dependencies]\n{dep}");
1727            let config_path = write_to_config(&config_contents, "soldeer.toml");
1728            let res = read_config_deps(config_path);
1729            assert!(matches!(res, Err(ConfigError::GitIdentifierConflict(_))), "{res:?}");
1730        }
1731    }
1732
1733    #[test]
1734    fn test_add_to_config() {
1735        let config_path = write_to_config("[dependencies]\n", "soldeer.toml");
1736
1737        let deps: &[Dependency] = &[
1738            HttpDependency::builder().name("lib1").version_req("1.0.0").build().into(),
1739            HttpDependency::builder()
1740                .name("lib2")
1741                .version_req("1.0.0")
1742                .url("https://test.com/test.zip")
1743                .build()
1744                .into(),
1745            HttpDependency::builder()
1746                .name("lib21")
1747                .version_req("1.0.0")
1748                .url("https://test.com/test.zip")
1749                .project_root("foo/bar")
1750                .build()
1751                .into(),
1752            GitDependency::builder()
1753                .name("lib3")
1754                .version_req("1.0.0")
1755                .git("https://example.com/repo.git")
1756                .build()
1757                .into(),
1758            GitDependency::builder()
1759                .name("lib4")
1760                .version_req("1.0.0")
1761                .git("https://example.com/repo.git")
1762                .identifier(GitIdentifier::from_rev("123456"))
1763                .build()
1764                .into(),
1765            GitDependency::builder()
1766                .name("lib5")
1767                .version_req("1.0.0")
1768                .git("https://example.com/repo.git")
1769                .identifier(GitIdentifier::from_branch("dev"))
1770                .build()
1771                .into(),
1772            GitDependency::builder()
1773                .name("lib6")
1774                .version_req("1.0.0")
1775                .git("https://example.com/repo.git")
1776                .identifier(GitIdentifier::from_tag("v1.0.0"))
1777                .build()
1778                .into(),
1779            GitDependency::builder()
1780                .name("lib7")
1781                .version_req("1.0.0")
1782                .git("https://example.com/repo.git")
1783                .project_root("foo/bar")
1784                .build()
1785                .into(),
1786        ];
1787        for dep in deps {
1788            let res = add_to_config(dep, &config_path);
1789            assert!(res.is_ok(), "{dep}: {res:?}");
1790        }
1791
1792        let (parsed, _) = read_config_deps(&config_path).unwrap();
1793        for (dep, parsed) in deps.iter().zip(parsed.iter()) {
1794            assert_eq!(dep, parsed);
1795        }
1796    }
1797
1798    #[test]
1799    fn test_add_to_config_no_section() {
1800        let config_path = write_to_config("", "soldeer.toml");
1801        let dep = Dependency::from_name_version("lib1~1.0.0", None, None).unwrap();
1802        let res = add_to_config(&dep, &config_path);
1803        assert!(res.is_ok(), "{res:?}");
1804        let (parsed, _) = read_config_deps(&config_path).unwrap();
1805        assert_eq!(parsed[0], dep);
1806    }
1807
1808    #[test]
1809    fn test_delete_from_config() {
1810        let config_contents = r#"[dependencies]
1811"lib1" = "1.0.0"
1812"lib2" = { version = "2.0.0" }
1813"lib3" = { version = "3.0.0", url = "https://example.com" }
1814"lib4" = { version = "4.0.0", git = "https://example.com/repo.git" }
1815"lib5" = { version = "5.0.0", git = "https://example.com/repo.git", rev = "123456" }
1816"lib6" = { version = "6.0.0", git = "https://example.com/repo.git", branch = "dev" }
1817"lib7" = { version = "7.0.0", git = "https://example.com/repo.git", tag = "v7.0.0" }
1818"lib8" = { version = "8.0.0", url = "https://example.com", project_root = "foo/bar" }
1819"lib9" = { version = "9.0.0", git = "https://example.com/repo.git", project_root = "foo/bar" }
1820        "#;
1821        let config_path = write_to_config(config_contents, "soldeer.toml");
1822        let res = delete_from_config("lib1", &config_path);
1823        assert!(res.is_ok(), "{res:?}");
1824        assert_eq!(res.unwrap().name(), "lib1");
1825        assert_eq!(read_config_deps(&config_path).unwrap().0.len(), 8);
1826
1827        let res = delete_from_config("lib2", &config_path);
1828        assert!(res.is_ok(), "{res:?}");
1829        assert_eq!(res.unwrap().name(), "lib2");
1830        assert_eq!(read_config_deps(&config_path).unwrap().0.len(), 7);
1831
1832        let res = delete_from_config("lib3", &config_path);
1833        assert!(res.is_ok(), "{res:?}");
1834        assert_eq!(res.unwrap().name(), "lib3");
1835        assert_eq!(read_config_deps(&config_path).unwrap().0.len(), 6);
1836
1837        let res = delete_from_config("lib4", &config_path);
1838        assert!(res.is_ok(), "{res:?}");
1839        assert_eq!(res.unwrap().name(), "lib4");
1840        assert_eq!(read_config_deps(&config_path).unwrap().0.len(), 5);
1841
1842        let res = delete_from_config("lib5", &config_path);
1843        assert!(res.is_ok(), "{res:?}");
1844        assert_eq!(res.unwrap().name(), "lib5");
1845        assert_eq!(read_config_deps(&config_path).unwrap().0.len(), 4);
1846
1847        let res = delete_from_config("lib6", &config_path);
1848        assert!(res.is_ok(), "{res:?}");
1849        assert_eq!(res.unwrap().name(), "lib6");
1850        assert_eq!(read_config_deps(&config_path).unwrap().0.len(), 3);
1851
1852        let res = delete_from_config("lib7", &config_path);
1853        assert!(res.is_ok(), "{res:?}");
1854        assert_eq!(res.unwrap().name(), "lib7");
1855        assert_eq!(read_config_deps(&config_path).unwrap().0.len(), 2);
1856
1857        let res = delete_from_config("lib8", &config_path);
1858        assert!(res.is_ok(), "{res:?}");
1859        assert_eq!(res.unwrap().name(), "lib8");
1860        assert_eq!(read_config_deps(&config_path).unwrap().0.len(), 1);
1861
1862        let res = delete_from_config("lib9", &config_path);
1863        assert!(res.is_ok(), "{res:?}");
1864        assert_eq!(res.unwrap().name(), "lib9");
1865        assert!(read_config_deps(&config_path).unwrap().0.is_empty());
1866    }
1867
1868    #[test]
1869    fn test_delete_from_config_missing() {
1870        let config_contents = r#"[dependencies]
1871"lib1" = "1.0.0"
1872        "#;
1873        let config_path = write_to_config(config_contents, "soldeer.toml");
1874        let res = delete_from_config("libfoo", &config_path);
1875        assert!(matches!(res, Err(ConfigError::MissingDependency(_))), "{res:?}");
1876    }
1877
1878    #[test]
1879    fn test_update_config_libs() {
1880        let config_contents = r#"[profile.default]
1881libs = ["lib"]
1882
1883[dependencies]
1884"#;
1885        let config_path = write_to_config(config_contents, "foundry.toml");
1886        let res = update_config_libs(&config_path);
1887        assert!(res.is_ok(), "{res:?}");
1888        let contents = fs::read_to_string(&config_path).unwrap();
1889        assert_eq!(
1890            contents,
1891            r#"[profile.default]
1892libs = ["lib", "dependencies"]
1893
1894[dependencies]
1895"#
1896        );
1897    }
1898
1899    #[test]
1900    fn test_update_config_profile_empty() {
1901        let config_contents = r#"[dependencies]
1902"#;
1903        let config_path = write_to_config(config_contents, "foundry.toml");
1904        let res = update_config_libs(&config_path);
1905        assert!(res.is_ok(), "{res:?}");
1906        let contents = fs::read_to_string(&config_path).unwrap();
1907        assert_eq!(
1908            contents,
1909            r#"[dependencies]
1910
1911[profile.default]
1912libs = ["dependencies"]
1913"#
1914        );
1915    }
1916
1917    #[test]
1918    fn test_update_config_libs_empty() {
1919        let config_contents = r#"[profile.default]
1920src = "src"
1921
1922[dependencies]
1923"#;
1924        let config_path = write_to_config(config_contents, "foundry.toml");
1925        let res = update_config_libs(&config_path);
1926        assert!(res.is_ok(), "{res:?}");
1927        let contents = fs::read_to_string(&config_path).unwrap();
1928        assert_eq!(
1929            contents,
1930            r#"[profile.default]
1931src = "src"
1932libs = ["dependencies"]
1933
1934[dependencies]
1935"#
1936        );
1937    }
1938
1939    #[test]
1940    fn test_parse_dependency() {
1941        let config_contents = r#"[dependencies]
1942"lib1" = "1.0.0"
1943"lib2" = { version = "2.0.0" }
1944"lib3" = { version = "3.0.0", url = "https://example.com" }
1945"lib4" = { version = "4.0.0", git = "https://example.com/repo.git" }
1946"lib5" = { version = "5.0.0", git = "https://example.com/repo.git", rev = "123456" }
1947"lib6" = { version = "6.0.0", git = "https://example.com/repo.git", branch = "dev" }
1948"lib7" = { version = "7.0.0", git = "https://example.com/repo.git", tag = "v7.0.0" }
1949"lib8" = { version = "8.0.0", url = "https://example.com", project_root = "foo/bar" }
1950"lib9" = { version = "9.0.0", git = "https://example.com/repo.git", project_root = "foo/bar" }
1951"#;
1952        let doc: DocumentMut = config_contents.parse::<DocumentMut>().unwrap();
1953        let data = doc.get("dependencies").map(|v| v.as_table()).unwrap().unwrap();
1954        for (name, v) in data {
1955            let res = parse_dependency(name, v);
1956            assert!(res.is_ok(), "{res:?}");
1957        }
1958    }
1959
1960    #[test]
1961    fn test_parse_dependency_extra_field() {
1962        let config_contents = r#"[dependencies]
1963"lib1" = { version = "3.0.0", url = "https://example.com", foo = "bar" }
1964"#;
1965        let doc: DocumentMut = config_contents.parse::<DocumentMut>().unwrap();
1966        let data = doc.get("dependencies").map(|v| v.as_table()).unwrap().unwrap();
1967        for (name, v) in data {
1968            let res = parse_dependency(name, v).unwrap();
1969            assert_eq!(res.warnings[0].message, "`foo` is not a valid dependency option");
1970        }
1971    }
1972
1973    #[test]
1974    fn test_parse_dependency_git_extra_url() {
1975        let config_contents = r#"[dependencies]
1976"lib1" = { version = "3.0.0", git = "https://example.com/repo.git", url = "https://example.com" }
1977"#;
1978        let doc: DocumentMut = config_contents.parse::<DocumentMut>().unwrap();
1979        let data = doc.get("dependencies").map(|v| v.as_table()).unwrap().unwrap();
1980        for (name, v) in data {
1981            let res = parse_dependency(name, v);
1982            assert!(
1983                matches!(
1984                    res,
1985                    Err(ConfigError::FieldConflict { field: _, conflicts_with: _, dep: _ })
1986                ),
1987                "{res:?}"
1988            );
1989        }
1990    }
1991
1992    #[test]
1993    fn test_parse_dependency_git_field_conflict() {
1994        let config_contents = r#"[dependencies]
1995"lib2" = { version = "3.0.0", git = "https://example.com/repo.git", rev = "123456", branch = "dev" }
1996"lib3" = { version = "3.0.0", git = "https://example.com/repo.git", rev = "123456", tag = "v7.0.0" }
1997"lib4" = { version = "3.0.0", git = "https://example.com/repo.git", branch = "dev", tag = "v7.0.0" }
1998"#;
1999        let doc: DocumentMut = config_contents.parse::<DocumentMut>().unwrap();
2000        let data = doc.get("dependencies").map(|v| v.as_table()).unwrap().unwrap();
2001        for (name, v) in data {
2002            let res = parse_dependency(name, v);
2003            assert!(matches!(res, Err(ConfigError::GitIdentifierConflict(_))), "{res:?}");
2004        }
2005    }
2006
2007    #[test]
2008    fn test_parse_dependency_missing_url() {
2009        let config_contents = r#"[dependencies]
2010"lib1" = { version = "3.0.0", rev = "123456" }
2011"lib2" = { version = "3.0.0", branch = "dev" }
2012"lib3" = { version = "3.0.0", tag = "v7.0.0" }
2013"#;
2014        let doc: DocumentMut = config_contents.parse::<DocumentMut>().unwrap();
2015        let data = doc.get("dependencies").map(|v| v.as_table()).unwrap().unwrap();
2016        for (name, v) in data {
2017            let res = parse_dependency(name, v).unwrap();
2018            assert!(res.warnings[0].message.ends_with("is ignored if no `git` URL is provided"));
2019        }
2020    }
2021
2022    #[test]
2023    fn test_find_git_root() {
2024        let test_dir = testdir!();
2025        let git_dir = test_dir.join(".git");
2026        fs::create_dir(&git_dir).unwrap();
2027
2028        let result = find_git_root(&test_dir);
2029        assert!(result.is_ok(), "{result:?}");
2030        assert_eq!(result.unwrap(), Some(test_dir.clone()));
2031
2032        // test with a subdirectory
2033        let sub_dir = test_dir.join("subdir");
2034        fs::create_dir(&sub_dir).unwrap();
2035
2036        let result = find_git_root(&sub_dir);
2037        assert!(result.is_ok(), "{result:?}");
2038        assert_eq!(result.unwrap(), Some(test_dir));
2039
2040        // test outside of a git folder
2041        let temp_dir = std::env::temp_dir().join("soldeer_test_no_git");
2042        if !temp_dir.exists() {
2043            fs::create_dir(&temp_dir).unwrap();
2044        }
2045
2046        let result = find_git_root(&temp_dir);
2047        assert_eq!(result.unwrap(), None);
2048
2049        // clean up
2050        fs::remove_dir(&temp_dir).unwrap();
2051    }
2052
2053    #[test]
2054    fn test_find_git_root_nested() {
2055        // test nested git repositories
2056        let outer_dir = testdir!();
2057        fs::create_dir(outer_dir.join(".git")).unwrap();
2058
2059        let inner_dir = outer_dir.join("inner");
2060        fs::create_dir(&inner_dir).unwrap();
2061        fs::create_dir(inner_dir.join(".git")).unwrap();
2062
2063        // should find the inner git root when starting from inner directory
2064        let result = find_git_root(&inner_dir);
2065        assert!(result.is_ok(), "{result:?}");
2066        assert_eq!(result.unwrap(), Some(inner_dir));
2067
2068        // should find the outer git root when starting from outer directory
2069        let result = find_git_root(&outer_dir);
2070        assert!(result.is_ok(), "{result:?}");
2071        assert_eq!(result.unwrap(), Some(outer_dir));
2072    }
2073
2074    #[test]
2075    fn test_find_project_root_with_foundry_toml() {
2076        let test_dir = testdir!();
2077        let foundry_toml = test_dir.join("foundry.toml");
2078        fs::write(&foundry_toml, "[dependencies]\n").unwrap();
2079
2080        let result = find_project_root(Some(&test_dir));
2081        assert!(result.is_ok(), "{result:?}");
2082        assert_eq!(result.unwrap(), test_dir);
2083    }
2084
2085    #[test]
2086    fn test_find_project_root_with_soldeer_toml() {
2087        let test_dir = testdir!();
2088        let soldeer_toml = test_dir.join("soldeer.toml");
2089        fs::write(&soldeer_toml, "[dependencies]\n").unwrap();
2090
2091        let result = find_project_root(Some(&test_dir));
2092        assert!(result.is_ok(), "{result:?}");
2093        assert_eq!(result.unwrap(), test_dir);
2094    }
2095
2096    #[test]
2097    fn test_find_project_root_in_subdirectory() {
2098        let test_dir = testdir!();
2099        let foundry_toml = test_dir.join("foundry.toml");
2100        fs::write(&foundry_toml, "[dependencies]\n").unwrap();
2101
2102        let sub_dir = test_dir.join("src");
2103        fs::create_dir(&sub_dir).unwrap();
2104
2105        let result = find_project_root(Some(&sub_dir));
2106        assert!(result.is_ok(), "{result:?}");
2107        assert_eq!(result.unwrap(), test_dir);
2108    }
2109
2110    #[test]
2111    fn test_find_project_root_git_boundary() {
2112        let test_dir = testdir!();
2113        let git_folder = test_dir.join(".git");
2114        fs::create_dir(&git_folder).unwrap();
2115
2116        let sub_dir = test_dir.join("src");
2117        fs::create_dir(&sub_dir).unwrap();
2118
2119        let result = find_project_root(Some(&sub_dir));
2120        assert!(result.is_ok(), "{result:?}");
2121        assert_eq!(result.unwrap(), test_dir);
2122    }
2123}