Skip to main content

foundry_config/
fix.rs

1//! Helpers to automatically fix configuration warnings.
2
3use crate::{Config, Warning, providers::TomlFileProvider};
4use std::{
5    fs, io,
6    ops::{Deref, DerefMut},
7    path::{Path, PathBuf},
8};
9
10/// A convenience wrapper around a TOML document and the path it was read from
11struct TomlFile {
12    doc: toml_edit::DocumentMut,
13    path: PathBuf,
14}
15
16impl TomlFile {
17    fn open(path: impl AsRef<Path>) -> eyre::Result<Self> {
18        let path = path.as_ref().to_owned();
19        let doc = fs::read_to_string(&path)?.parse()?;
20        Ok(Self { doc, path })
21    }
22
23    const fn doc(&self) -> &toml_edit::DocumentMut {
24        &self.doc
25    }
26
27    fn path(&self) -> &Path {
28        self.path.as_ref()
29    }
30
31    fn save(&self) -> io::Result<()> {
32        fs::write(self.path(), self.doc().to_string())
33    }
34}
35
36impl Deref for TomlFile {
37    type Target = toml_edit::DocumentMut;
38    fn deref(&self) -> &Self::Target {
39        self.doc()
40    }
41}
42
43impl DerefMut for TomlFile {
44    fn deref_mut(&mut self) -> &mut Self::Target {
45        &mut self.doc
46    }
47}
48
49/// The error emitted when failing to insert into a profile.
50#[derive(Debug)]
51struct InsertProfileError {
52    pub message: String,
53    pub value: toml_edit::Item,
54}
55
56impl std::fmt::Display for InsertProfileError {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.write_str(&self.message)
59    }
60}
61
62impl std::error::Error for InsertProfileError {}
63
64impl TomlFile {
65    /// Insert a name as `[profile.name]`. Creating the `[profile]` table where necessary and
66    /// throwing an error if there exists a conflict
67    #[expect(clippy::result_large_err)]
68    fn insert_profile(
69        &mut self,
70        profile_str: &str,
71        value: toml_edit::Item,
72    ) -> Result<(), InsertProfileError> {
73        if !value.is_table_like() {
74            return Err(InsertProfileError {
75                message: format!("Expected [{profile_str}] to be a Table"),
76                value,
77            });
78        }
79        // get or create the profile section
80        let profile_map = if let Some(map) = self.get_mut(Config::PROFILE_SECTION) {
81            map
82        } else {
83            // insert profile section at the beginning of the map
84            let mut profile_section = toml_edit::Table::new();
85            profile_section.set_position(Some(0));
86            profile_section.set_implicit(true);
87            self.insert(Config::PROFILE_SECTION, toml_edit::Item::Table(profile_section));
88            self.get_mut(Config::PROFILE_SECTION).expect("exists per above")
89        };
90        // ensure the profile section is a table
91        let profile_map = if let Some(table) = profile_map.as_table_like_mut() {
92            table
93        } else {
94            return Err(InsertProfileError {
95                message: format!("Expected [{}] to be a Table", Config::PROFILE_SECTION),
96                value,
97            });
98        };
99        // check the profile map for structure and existing keys
100        if let Some(profile) = profile_map.get(profile_str) {
101            if let Some(profile_table) = profile.as_table_like() {
102                if !profile_table.is_empty() {
103                    return Err(InsertProfileError {
104                        message: format!(
105                            "[{}.{}] already exists",
106                            Config::PROFILE_SECTION,
107                            profile_str
108                        ),
109                        value,
110                    });
111                }
112            } else {
113                return Err(InsertProfileError {
114                    message: format!(
115                        "Expected [{}.{}] to be a Table",
116                        Config::PROFILE_SECTION,
117                        profile_str
118                    ),
119                    value,
120                });
121            }
122        }
123        // insert the profile
124        profile_map.insert(profile_str, value);
125        Ok(())
126    }
127}
128
129/// Making sure any implicit profile `[name]` becomes `[profile.name]` for the given file and
130/// returns the implicit profiles and the result of editing them
131fn fix_toml_non_strict_profiles(
132    toml_file: &mut TomlFile,
133) -> Vec<(String, Result<(), InsertProfileError>)> {
134    let mut results = vec![];
135
136    // get any non root level keys that need to be inserted into [profile]
137    let profiles = toml_file
138        .as_table()
139        .iter()
140        .map(|(k, _)| k.to_string())
141        .filter(|k| !Config::is_standalone_section(k))
142        .collect::<Vec<_>>();
143
144    // remove each profile and insert into [profile] section
145    for profile in profiles {
146        if let Some(value) = toml_file.remove(&profile) {
147            let res = toml_file.insert_profile(&profile, value);
148            if let Err(err) = res.as_ref() {
149                toml_file.insert(&profile, err.value.clone());
150            }
151            results.push((profile, res))
152        }
153    }
154    results
155}
156
157/// Fix foundry.toml files. Making sure any implicit profile `[name]` becomes
158/// `[profile.name]`. Return any warnings
159pub fn fix_tomls() -> Vec<Warning> {
160    fix_tomls_with_root(".")
161}
162
163/// Fixes the global TOML and the local TOML selected by `FOUNDRY_CONFIG` or `root/foundry.toml`.
164///
165/// Relative `FOUNDRY_CONFIG` paths are relative to the current working directory, as when loading
166/// configuration. This only parses TOML, so invalid configuration values do not prevent migration.
167pub fn fix_tomls_with_root(root: impl AsRef<Path>) -> Vec<Warning> {
168    let mut warnings = vec![];
169
170    let tomls = {
171        let mut tomls = vec![];
172        if let Some(global_toml) = Config::foundry_dir_toml().filter(|p| p.exists()) {
173            tomls.push(global_toml);
174        }
175        let local_toml =
176            TomlFileProvider::new(Some("FOUNDRY_CONFIG"), root.as_ref().join(Config::FILE_NAME))
177                .file();
178        if local_toml.exists() {
179            tomls.push(local_toml);
180        } else {
181            warnings.push(Warning::NoLocalToml(local_toml));
182        }
183        tomls
184    };
185
186    for toml in tomls {
187        let mut toml_file = match TomlFile::open(&toml) {
188            Ok(toml_file) => toml_file,
189            Err(err) => {
190                warnings.push(Warning::CouldNotReadToml { path: toml, err: err.to_string() });
191                continue;
192            }
193        };
194
195        let results = fix_toml_non_strict_profiles(&mut toml_file);
196        let was_edited = results.iter().any(|(_, res)| res.is_ok());
197        for (profile, err) in results
198            .into_iter()
199            .filter_map(|(profile, res)| res.err().map(|err| (profile, err.message)))
200        {
201            warnings.push(Warning::CouldNotFixProfile {
202                path: toml_file.path().into(),
203                profile,
204                err,
205            })
206        }
207
208        if was_edited && let Err(err) = toml_file.save() {
209            warnings.push(Warning::CouldNotWriteToml {
210                path: toml_file.path().into(),
211                err: err.to_string(),
212            });
213        }
214    }
215
216    warnings
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use figment::Jail;
223    use similar_asserts::assert_eq;
224
225    macro_rules! fix_test {
226        ($(#[$attr:meta])* $name:ident, $fun:expr) => {
227            #[test]
228            $(#[$attr])*
229            fn $name() {
230                Jail::expect_with(|jail| {
231                    // setup home directory,
232                    // **Note** this only has an effect on unix, as [`dirs::home_dir()`] on windows uses `FOLDERID_Profile`
233                    jail.set_env("HOME", jail.directory().display().to_string());
234                    std::fs::create_dir(jail.directory().join(".foundry")).unwrap();
235
236                    // define function type to allow implicit params / return
237                    let f: Box<dyn FnOnce(&mut Jail) -> Result<(), figment::Error>> = Box::new($fun);
238                    f(jail)?;
239
240                    Ok(())
241                });
242            }
243        };
244    }
245
246    fix_test!(test_implicit_profile_name_changed, |jail| {
247        jail.create_file(
248            "foundry.toml",
249            r#"
250                [default]
251                src = "src"
252                # comment
253
254                [other]
255                src = "other-src"
256            "#,
257        )?;
258        fix_tomls();
259        assert_eq!(
260            fs::read_to_string("foundry.toml").unwrap(),
261            r#"
262                [profile.default]
263                src = "src"
264                # comment
265
266                [profile.other]
267                src = "other-src"
268            "#
269        );
270        Ok(())
271    });
272
273    fix_test!(test_leave_standalone_sections_alone, |jail| {
274        jail.create_file(
275            "foundry.toml",
276            r#"
277                [default]
278                src = "src"
279
280                [fmt]
281                line_length = 100
282
283                [rpc_endpoints]
284                optimism = "https://example.com/"
285            "#,
286        )?;
287        fix_tomls();
288        assert_eq!(
289            fs::read_to_string("foundry.toml").unwrap(),
290            r#"
291                [profile.default]
292                src = "src"
293
294                [fmt]
295                line_length = 100
296
297                [rpc_endpoints]
298                optimism = "https://example.com/"
299            "#
300        );
301        Ok(())
302    });
303
304    // mocking the `$HOME` has no effect on windows, see [`dirs::home_dir()`]
305    fix_test!(
306        #[cfg(not(windows))]
307        test_global_toml_is_edited,
308        |jail| {
309            jail.create_file(
310                "foundry.toml",
311                r#"
312                [other]
313                src = "other-src"
314            "#,
315            )?;
316            jail.create_file(
317                ".foundry/foundry.toml",
318                r#"
319                [default]
320                src = "src"
321            "#,
322            )?;
323            fix_tomls();
324            assert_eq!(
325                fs::read_to_string("foundry.toml").unwrap(),
326                r#"
327                [profile.other]
328                src = "other-src"
329            "#
330            );
331            assert_eq!(
332                fs::read_to_string(".foundry/foundry.toml").unwrap(),
333                r#"
334                [profile.default]
335                src = "src"
336            "#
337            );
338            Ok(())
339        }
340    );
341}