Skip to main content

foundry_config/
utils.rs

1//! Utility functions
2
3use crate::Config;
4use alloy_primitives::U256;
5use figment::value::Value;
6use foundry_compilers::artifacts::remappings::{Remapping, RemappingError};
7use serde::{Deserialize, Deserializer, Serializer, de::Error};
8use std::{
9    io,
10    path::{Path, PathBuf},
11    str::FromStr,
12};
13
14// TODO: Why do these exist separately from `Config::load`?
15
16/// Loads the config for the current project workspace.
17pub fn load_config() -> eyre::Result<Config> {
18    load_config_with_root(None)
19}
20
21/// Loads the config for the current project workspace or the provided root path.
22pub fn load_config_with_root(root: Option<&Path>) -> eyre::Result<Config> {
23    let root = match root {
24        Some(root) => root,
25        None => &find_project_root(None)?,
26    };
27    Ok(Config::load_with_root(root)?.sanitized())
28}
29
30/// Returns the path of the top-level directory of the working git tree.
31pub fn find_git_root(relative_to: &Path) -> io::Result<Option<PathBuf>> {
32    let root =
33        if relative_to.is_absolute() { relative_to } else { &dunce::canonicalize(relative_to)? };
34    Ok(root.ancestors().find(|p| p.join(".git").exists()).map(Path::to_path_buf))
35}
36
37/// Returns the root path to set for the project root.
38///
39/// Traverse the dir tree up and look for a `foundry.toml` file starting at the given path or cwd,
40/// but only until the root dir of the current repo so that:
41///
42/// ```text
43/// -- foundry.toml
44///
45/// -- repo
46///   |__ .git
47///   |__sub
48///      |__ [given_path | cwd]
49/// ```
50///
51/// will still detect `repo` as root.
52///
53/// Returns `repo` or `cwd` if no `foundry.toml` is found in the tree.
54///
55/// Returns an error if:
56/// - `cwd` is `Some` and is not a valid directory;
57/// - `cwd` is `None` and the [`std::env::current_dir`] call fails.
58pub fn find_project_root(cwd: Option<&Path>) -> io::Result<PathBuf> {
59    let cwd = match cwd {
60        Some(path) => path,
61        None => &std::env::current_dir()?,
62    };
63    let boundary = find_git_root(cwd)?;
64    let found = cwd
65        .ancestors()
66        // Don't look outside of the git repo if it exists.
67        .take_while(|p| if let Some(boundary) = &boundary { p.starts_with(boundary) } else { true })
68        .find(|p| p.join(Config::FILE_NAME).is_file())
69        .map(Path::to_path_buf);
70    Ok(found.or(boundary).unwrap_or_else(|| cwd.to_path_buf()))
71}
72
73/// Returns all [`Remapping`]s contained in the `remappings` str separated by newlines
74///
75/// # Example
76///
77/// ```
78/// use foundry_config::remappings_from_newline;
79/// let remappings: Result<Vec<_>, _> = remappings_from_newline(
80///     r#"
81///              file-ds-test/=lib/ds-test/
82///              file-other/=lib/other/
83///          "#,
84/// )
85/// .collect();
86/// ```
87pub fn remappings_from_newline(
88    remappings: &str,
89) -> impl Iterator<Item = Result<Remapping, RemappingError>> + '_ {
90    remappings.lines().map(|x| x.trim()).filter(|x| !x.is_empty()).map(Remapping::from_str)
91}
92
93/// Returns the remappings from the given var
94///
95/// Returns `None` if the env var is not set, otherwise all Remappings, See
96/// `remappings_from_newline`
97pub fn remappings_from_env_var(env_var: &str) -> Option<Result<Vec<Remapping>, RemappingError>> {
98    let val = std::env::var(env_var).ok()?;
99    Some(remappings_from_newline(&val).collect())
100}
101
102/// Converts the `val` into a `figment::Value::Array`
103///
104/// The values should be separated by commas, surrounding brackets are also supported `[a,b,c]`
105pub fn to_array_value(val: &str) -> Result<Value, figment::Error> {
106    let value: Value = match Value::from(val) {
107        Value::String(_, val) => val
108            .trim_start_matches('[')
109            .trim_end_matches(']')
110            .split(',')
111            .map(|s| s.to_string())
112            .collect::<Vec<_>>()
113            .into(),
114        Value::Empty(_, _) => Vec::<Value>::new().into(),
115        val @ Value::Array(_, _) => val,
116        _ => return Err(format!("Invalid value `{val}`, expected an array").into()),
117    };
118    Ok(value)
119}
120
121/// Returns a list of _unique_ paths to all folders under `root` that contain a `foundry.toml` file
122///
123/// This will also resolve symlinks
124///
125/// # Example
126///
127/// ```no_run
128/// use foundry_config::utils;
129/// let dirs = utils::foundry_toml_dirs("./lib");
130/// ```
131///
132/// for following layout this will return
133/// `["lib/dep1", "lib/dep2"]`
134///
135/// ```text
136/// lib
137/// └── dep1
138/// │   ├── foundry.toml
139/// └── dep2
140///     ├── foundry.toml
141/// ```
142pub fn foundry_toml_dirs(root: impl AsRef<Path>) -> Vec<PathBuf> {
143    walkdir::WalkDir::new(root)
144        .max_depth(1)
145        .into_iter()
146        .filter_map(Result::ok)
147        .filter(|entry| entry.file_type().is_dir())
148        .filter_map(|entry| dunce::canonicalize(entry.path()).ok())
149        .filter(|path| path.join(Config::FILE_NAME).exists())
150        .collect()
151}
152
153/// A depth-one dependency path and the canonical directory it resolves to.
154#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
155pub(crate) struct FoundryTomlDir {
156    /// The canonical directory used to identify the dependency config.
157    pub canonical: PathBuf,
158    /// The lexical path under the configured dependency directory.
159    pub path: PathBuf,
160}
161
162/// Returns depth-one dependency entries that resolve to directories containing `foundry.toml`.
163pub(crate) fn foundry_toml_dir_entries(root: impl AsRef<Path>) -> Vec<FoundryTomlDir> {
164    let mut dirs = walkdir::WalkDir::new(root)
165        .max_depth(1)
166        .into_iter()
167        .filter_map(Result::ok)
168        .filter_map(|entry| {
169            let canonical = dunce::canonicalize(entry.path()).ok()?;
170            (canonical.is_dir() && canonical.join(Config::FILE_NAME).exists())
171                .then(|| FoundryTomlDir { path: entry.path().to_path_buf(), canonical })
172        })
173        .collect::<Vec<_>>();
174    dirs.sort_unstable_by(|a, b| a.canonical.cmp(&b.canonical).then_with(|| a.path.cmp(&b.path)));
175    dirs.dedup();
176    dirs
177}
178
179/// Returns a remapping for the given dir
180pub(crate) fn get_dir_remapping(dir: impl AsRef<Path>) -> Option<Remapping> {
181    let dir = dir.as_ref();
182    if let Some(dir_name) = dir.file_name().and_then(|s| s.to_str()).filter(|s| !s.is_empty()) {
183        let mut r = Remapping {
184            context: None,
185            name: format!("{dir_name}/"),
186            path: format!("{}", dir.display()),
187        };
188        if !r.path.ends_with('/') {
189            r.path.push('/')
190        }
191        Some(r)
192    } else {
193        None
194    }
195}
196
197/// Deserialize stringified percent. The value must be between 0 and 100 inclusive.
198pub(crate) fn deserialize_stringified_percent<'de, D>(deserializer: D) -> Result<u32, D::Error>
199where
200    D: Deserializer<'de>,
201{
202    let num: U256 = Numeric::deserialize(deserializer)?.into();
203    let num: u64 = num.try_into().map_err(serde::de::Error::custom)?;
204    if num <= 100 {
205        num.try_into().map_err(serde::de::Error::custom)
206    } else {
207        Err(serde::de::Error::custom("percent must be lte 100"))
208    }
209}
210
211/// Deserialize a `u64` or "max" for `u64::MAX`.
212pub(crate) fn deserialize_u64_or_max<'de, D>(deserializer: D) -> Result<u64, D::Error>
213where
214    D: Deserializer<'de>,
215{
216    #[derive(Deserialize)]
217    #[serde(untagged)]
218    enum Val {
219        Number(u64),
220        String(String),
221    }
222
223    match Val::deserialize(deserializer)? {
224        Val::Number(num) => Ok(num),
225        Val::String(s) if s.eq_ignore_ascii_case("max") => Ok(u64::MAX),
226        Val::String(s) => s.parse::<u64>().map_err(D::Error::custom),
227    }
228}
229
230/// Deserialize a `usize` or "max" for `usize::MAX`.
231pub(crate) fn deserialize_usize_or_max<'de, D>(deserializer: D) -> Result<usize, D::Error>
232where
233    D: Deserializer<'de>,
234{
235    deserialize_u64_or_max(deserializer)?.try_into().map_err(D::Error::custom)
236}
237
238/// Serialize a `usize` as `"max"` if it equals `usize::MAX`, as a string if it exceeds
239/// `i64::MAX` (TOML integer limit), or as a plain number otherwise.
240pub(crate) fn serialize_usize_or_max<S>(value: &usize, serializer: S) -> Result<S::Ok, S::Error>
241where
242    S: Serializer,
243{
244    if *value == usize::MAX {
245        serializer.serialize_str("max")
246    } else if *value > i64::MAX as usize {
247        serializer.serialize_str(&value.to_string())
248    } else {
249        serializer.serialize_u64(*value as u64)
250    }
251}
252
253/// Deserialize into `U256` from either a `u64`, a `U256` hex string, or a decimal string.
254pub fn deserialize_u64_to_u256<'de, D>(deserializer: D) -> Result<U256, D::Error>
255where
256    D: Deserializer<'de>,
257{
258    #[derive(Deserialize)]
259    #[serde(untagged)]
260    enum NumericValue {
261        U256(U256),
262        U64(u64),
263        String(String),
264    }
265
266    match NumericValue::deserialize(deserializer)? {
267        NumericValue::U64(n) => Ok(U256::from(n)),
268        NumericValue::U256(n) => Ok(n),
269        NumericValue::String(s) => {
270            // Handle decimal strings (e.g., "18446744073709551615")
271            U256::from_str(&s).map_err(D::Error::custom)
272        }
273    }
274}
275
276/// Serialize `U256` as `u64` if it fits, otherwise as a hex string.
277/// If the number fits into a i64, serialize it as number without quotation marks.
278/// If the number fits into a u64, serialize it as a stringified number with quotation marks.
279/// Otherwise, serialize it as a hex string with quotation marks.
280pub fn serialize_u64_or_u256<S>(n: &U256, serializer: S) -> Result<S::Ok, S::Error>
281where
282    S: Serializer,
283{
284    // The TOML specification handles integers as i64 so the number representation is limited to
285    // i64. If the number is larger than `i64::MAX` and up to `u64::MAX`, we serialize it as a
286    // string to avoid losing precision.
287    if let Ok(n_i64) = i64::try_from(*n) {
288        serializer.serialize_i64(n_i64)
289    } else if let Ok(n_u64) = u64::try_from(*n) {
290        serializer.serialize_str(&n_u64.to_string())
291    } else {
292        serializer.serialize_str(&format!("{n:#x}"))
293    }
294}
295
296/// Helper type to parse both `u64` and `U256`
297#[derive(Clone, Copy, Deserialize)]
298#[serde(untagged)]
299pub enum Numeric {
300    /// A [U256] value.
301    U256(U256),
302    /// A `u64` value.
303    Num(u64),
304}
305
306impl From<Numeric> for U256 {
307    fn from(n: Numeric) -> Self {
308        match n {
309            Numeric::U256(n) => n,
310            Numeric::Num(n) => Self::from(n),
311        }
312    }
313}
314
315impl FromStr for Numeric {
316    type Err = String;
317
318    fn from_str(s: &str) -> Result<Self, Self::Err> {
319        if s.starts_with("0x") {
320            U256::from_str_radix(s, 16).map(Numeric::U256).map_err(|err| err.to_string())
321        } else {
322            U256::from_str(s).map(Numeric::U256).map_err(|err| err.to_string())
323        }
324    }
325}
326
327#[cfg(all(test, unix))]
328mod tests {
329    use super::{FoundryTomlDir, foundry_toml_dir_entries, foundry_toml_dirs};
330    use std::{fs, os::unix::fs::symlink};
331    use tempfile::tempdir;
332
333    #[test]
334    fn finds_physical_foundry_toml_dirs_without_recursing() {
335        let temp = tempdir().unwrap();
336        let root = temp.path().join("lib");
337        let dependency = root.join("dependency");
338        let nested = dependency.join("nested");
339        fs::create_dir_all(&nested).unwrap();
340        fs::write(dependency.join("foundry.toml"), "").unwrap();
341        fs::write(nested.join("foundry.toml"), "").unwrap();
342
343        assert_eq!(foundry_toml_dirs(&root), vec![dunce::canonicalize(dependency).unwrap()]);
344    }
345
346    #[test]
347    fn preserves_sorted_aliases_for_the_same_canonical_dependency() {
348        let temp = tempdir().unwrap();
349        let root = temp.path().join("lib");
350        let dependency = temp.path().join("dependency");
351        fs::create_dir_all(&root).unwrap();
352        fs::create_dir_all(&dependency).unwrap();
353        fs::write(dependency.join("foundry.toml"), "").unwrap();
354        symlink(&dependency, root.join("z-alias")).unwrap();
355        symlink(&dependency, root.join("a-alias")).unwrap();
356
357        let canonical = dunce::canonicalize(dependency).unwrap();
358        assert_eq!(
359            foundry_toml_dir_entries(&root),
360            vec![
361                FoundryTomlDir { path: root.join("a-alias"), canonical: canonical.clone() },
362                FoundryTomlDir { path: root.join("z-alias"), canonical },
363            ]
364        );
365    }
366
367    #[test]
368    fn sorts_distinct_dependencies_by_canonical_path() {
369        let temp = tempdir().unwrap();
370        let root = temp.path().join("lib");
371        let dependency_a = temp.path().join("dependency-a");
372        let dependency_z = temp.path().join("dependency-z");
373        fs::create_dir_all(&root).unwrap();
374        for dependency in [&dependency_z, &dependency_a] {
375            fs::create_dir_all(dependency).unwrap();
376            fs::write(dependency.join("foundry.toml"), "").unwrap();
377        }
378        symlink(&dependency_z, root.join("a-alias")).unwrap();
379        symlink(&dependency_a, root.join("z-alias")).unwrap();
380
381        let canonical = foundry_toml_dir_entries(&root)
382            .into_iter()
383            .map(|entry| entry.canonical)
384            .collect::<Vec<_>>();
385        assert_eq!(
386            canonical,
387            vec![
388                dunce::canonicalize(dependency_a).unwrap(),
389                dunce::canonicalize(dependency_z).unwrap(),
390            ]
391        );
392    }
393
394    #[test]
395    fn ignores_broken_and_cyclic_symlinks() {
396        let temp = tempdir().unwrap();
397        let root = temp.path().join("lib");
398        fs::create_dir_all(&root).unwrap();
399        symlink("missing", root.join("broken")).unwrap();
400        symlink("cycle-b", root.join("cycle-a")).unwrap();
401        symlink("cycle-a", root.join("cycle-b")).unwrap();
402
403        assert!(foundry_toml_dir_entries(&root).is_empty());
404    }
405}