forge/
lockfile.rs

1//! foundry.lock handler type.
2
3use alloy_primitives::map::HashMap;
4use eyre::{OptionExt, Result};
5use foundry_cli::utils::Git;
6use serde::{Deserialize, Serialize};
7use std::{
8    collections::{BTreeMap, hash_map::Entry},
9    path::{Path, PathBuf},
10};
11
12pub const FOUNDRY_LOCK: &str = "foundry.lock";
13
14/// A type alias for a HashMap of dependencies keyed by relative path to the submodule dir.
15pub type DepMap = HashMap<PathBuf, DepIdentifier>;
16
17/// A lockfile handler that keeps track of the dependencies and their current state.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Lockfile<'a> {
20    /// A map of the dependencies keyed by relative path to the submodule dir.
21    #[serde(flatten)]
22    deps: DepMap,
23    /// This is optional to handle no-git scencarios.
24    #[serde(skip)]
25    git: Option<&'a Git<'a>>,
26    /// Absolute path to the lockfile.
27    #[serde(skip)]
28    lockfile_path: PathBuf,
29}
30
31impl<'a> Lockfile<'a> {
32    /// Create a new [`Lockfile`] instance.
33    ///
34    /// `project_root` is the absolute path to the project root.
35    ///
36    /// You will need to call [`Lockfile::read`] or [`Lockfile::sync`] to load the lockfile.
37    pub fn new(project_root: &Path) -> Self {
38        Self { deps: HashMap::default(), git: None, lockfile_path: project_root.join(FOUNDRY_LOCK) }
39    }
40
41    /// Set the git instance to be used for submodule operations.
42    pub fn with_git(mut self, git: &'a Git<'_>) -> Self {
43        self.git = Some(git);
44        self
45    }
46
47    /// Sync the foundry.lock file with the current state of `git submodules`.
48    ///
49    /// If the lockfile and git submodules are out of sync, it returns a [`DepMap`] consisting of
50    /// _only_ the out-of-sync dependencies.
51    ///
52    /// This method writes the lockfile to project root if:
53    /// - The lockfile does not exist.
54    /// - The lockfile is out of sync with the git submodules.
55    pub fn sync(&mut self, lib: &Path) -> Result<Option<DepMap>> {
56        match self.read() {
57            Ok(_) => {}
58            Err(e) => {
59                if !e.to_string().contains("Lockfile not found") {
60                    return Err(e);
61                }
62            }
63        }
64
65        if let Some(git) = &self.git {
66            let submodules = git.submodules()?;
67
68            if submodules.is_empty() {
69                trace!("No submodules found. Skipping sync.");
70                return Ok(None);
71            }
72
73            let modules_with_branch = git
74                .read_submodules_with_branch(&Git::root_of(git.root)?, lib.file_name().unwrap())?;
75
76            let mut out_of_sync: DepMap = HashMap::default();
77            for sub in &submodules {
78                let rel_path = sub.path();
79                let rev = sub.rev();
80
81                let entry = self.deps.entry(rel_path.to_path_buf());
82
83                match entry {
84                    Entry::Occupied(e) => {
85                        if e.get().rev() != rev {
86                            out_of_sync.insert(rel_path.to_path_buf(), e.get().clone());
87                        }
88                    }
89                    Entry::Vacant(e) => {
90                        // Check if there is branch specified for the submodule at rel_path in
91                        // .gitmodules
92                        let maybe_branch = modules_with_branch.get(rel_path).map(|b| b.to_string());
93
94                        trace!(?maybe_branch, submodule = ?rel_path, "submodule branch");
95                        if let Some(branch) = maybe_branch {
96                            let dep_id = DepIdentifier::Branch {
97                                name: branch,
98                                rev: rev.to_string(),
99                                r#override: false,
100                            };
101                            e.insert(dep_id.clone());
102                            out_of_sync.insert(rel_path.to_path_buf(), dep_id);
103                            continue;
104                        }
105
106                        let dep_id = DepIdentifier::Rev { rev: rev.to_string(), r#override: false };
107                        trace!(submodule=?rel_path, ?dep_id, "submodule dep_id");
108                        e.insert(dep_id.clone());
109                        out_of_sync.insert(rel_path.to_path_buf(), dep_id);
110                    }
111                }
112            }
113
114            return Ok(if out_of_sync.is_empty() { None } else { Some(out_of_sync) });
115        }
116
117        Ok(None)
118    }
119
120    /// Loads the lockfile from the project root.
121    ///
122    /// Throws an error if the lockfile does not exist.
123    pub fn read(&mut self) -> Result<()> {
124        if !self.lockfile_path.exists() {
125            return Err(eyre::eyre!("Lockfile not found at {}", self.lockfile_path.display()));
126        }
127
128        let lockfile_str = foundry_common::fs::read_to_string(&self.lockfile_path)?;
129
130        self.deps = serde_json::from_str(&lockfile_str)?;
131
132        trace!(lockfile = ?self.deps, "loaded lockfile");
133
134        Ok(())
135    }
136
137    /// Writes the lockfile to the project root.
138    pub fn write(&self) -> Result<()> {
139        let ordered_deps: BTreeMap<_, _> = self.deps.clone().into_iter().collect();
140        foundry_common::fs::write_pretty_json_file(&self.lockfile_path, &ordered_deps)?;
141        trace!(at= ?self.lockfile_path, "wrote lockfile");
142
143        Ok(())
144    }
145
146    /// Insert a dependency into the lockfile.
147    /// If the dependency already exists, it will be updated.
148    ///
149    /// Note: This does not write the updated lockfile to disk, only inserts the dep in-memory.
150    pub fn insert(&mut self, path: PathBuf, dep_id: DepIdentifier) {
151        self.deps.insert(path, dep_id);
152    }
153
154    /// Get the [`DepIdentifier`] for a submodule at a given path.
155    pub fn get(&self, path: &Path) -> Option<&DepIdentifier> {
156        self.deps.get(path)
157    }
158
159    /// Removes a dependency from the lockfile.
160    ///
161    /// Note: This does not write the updated lockfile to disk, only removes the dep in-memory.
162    pub fn remove(&mut self, path: &Path) -> Option<DepIdentifier> {
163        self.deps.remove(path)
164    }
165
166    /// Override a dependency in the lockfile.
167    ///
168    /// Returns the overridden/previous [`DepIdentifier`].
169    /// This is used in `forge update` to decide whether a dep's tag/branch/rev should be updated.
170    ///
171    /// Throws an error if the dependency is not found in the lockfile.
172    pub fn override_dep(
173        &mut self,
174        dep: &Path,
175        mut new_dep_id: DepIdentifier,
176    ) -> Result<DepIdentifier> {
177        let prev = self
178            .deps
179            .get_mut(dep)
180            .map(|d| {
181                new_dep_id.mark_override();
182                std::mem::replace(d, new_dep_id)
183            })
184            .ok_or_eyre(format!("Dependency not found in lockfile: {}", dep.display()))?;
185
186        Ok(prev)
187    }
188
189    /// Returns the num of dependencies in the lockfile.
190    pub fn len(&self) -> usize {
191        self.deps.len()
192    }
193
194    /// Returns whether the lockfile is empty.
195    pub fn is_empty(&self) -> bool {
196        self.deps.is_empty()
197    }
198
199    /// Returns an iterator over the lockfile.
200    pub fn iter(&self) -> impl Iterator<Item = (&PathBuf, &DepIdentifier)> {
201        self.deps.iter()
202    }
203
204    /// Returns an mutable iterator over the lockfile.
205    pub fn iter_mut(&mut self) -> impl Iterator<Item = (&PathBuf, &mut DepIdentifier)> {
206        self.deps.iter_mut()
207    }
208
209    pub fn exists(&self) -> bool {
210        self.lockfile_path.exists()
211    }
212}
213
214// Implement .iter() for &LockFile
215
216/// Identifies whether a dependency (submodule) is referenced by a branch,
217/// tag or rev (commit hash).
218///
219/// Each enum variant consists of an `r#override` flag which is used in `forge update` to decide
220/// whether to update a dep or not. This flag is skipped during serialization.
221#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
222pub enum DepIdentifier {
223    /// `name` of the branch and the `rev`  it is currently pointing to.
224    /// Running `forge update`, will update the `name` branch to the latest `rev`.
225    #[serde(rename = "branch")]
226    Branch {
227        name: String,
228        rev: String,
229        #[serde(skip)]
230        r#override: bool,
231    },
232    /// Release tag `name` and the `rev` it is currently pointing to.
233    /// Running `forge update` does not update the tag/rev.
234    /// Dependency will remain pinned to the existing tag/rev unless r#override like so `forge
235    /// update owner/dep@tag=different_tag`.
236    #[serde(rename = "tag")]
237    Tag {
238        name: String,
239        rev: String,
240        #[serde(skip)]
241        r#override: bool,
242    },
243    /// Commit hash `rev` the submodule is currently pointing to.
244    /// Running `forge update` does not update the rev.
245    /// Dependency will remain pinned to the existing rev unless r#override.
246    #[serde(rename = "rev", untagged)]
247    Rev {
248        rev: String,
249        #[serde(skip)]
250        r#override: bool,
251    },
252}
253
254impl DepIdentifier {
255    /// Resolves the [`DepIdentifier`] for a submodule at a given path.
256    /// `lib_path` is the absolute path to the submodule.
257    pub fn resolve_type(git: &Git<'_>, lib_path: &Path, s: &str) -> Result<Self> {
258        trace!(lib_path = ?lib_path, resolving_type = ?s, "resolving submodule identifier");
259        // Get the tags for the submodule
260        if git.has_tag(s, lib_path)? {
261            let rev = git.get_rev(s, lib_path)?;
262            return Ok(Self::Tag { name: String::from(s), rev, r#override: false });
263        }
264
265        if git.has_branch(s, lib_path)? {
266            let rev = git.get_rev(s, lib_path)?;
267            return Ok(Self::Branch { name: String::from(s), rev, r#override: false });
268        }
269
270        if git.has_rev(s, lib_path)? {
271            return Ok(Self::Rev { rev: String::from(s), r#override: false });
272        }
273
274        Err(eyre::eyre!("Could not resolve tag type for submodule at path {}", lib_path.display()))
275    }
276
277    /// Get the commit hash of the dependency.
278    pub fn rev(&self) -> &str {
279        match self {
280            Self::Branch { rev, .. } => rev,
281            Self::Tag { rev, .. } => rev,
282            Self::Rev { rev, .. } => rev,
283        }
284    }
285
286    /// Get the name of the dependency.
287    ///
288    /// In case of a Rev, this will return the commit hash.
289    pub fn name(&self) -> &str {
290        match self {
291            Self::Branch { name, .. } => name,
292            Self::Tag { name, .. } => name,
293            Self::Rev { rev, .. } => rev,
294        }
295    }
296
297    /// Get the name/rev to checkout at.
298    pub fn checkout_id(&self) -> &str {
299        match self {
300            Self::Branch { name, .. } => name,
301            Self::Tag { name, .. } => name,
302            Self::Rev { rev, .. } => rev,
303        }
304    }
305
306    /// Marks as dependency as overridden.
307    pub fn mark_override(&mut self) {
308        match self {
309            Self::Branch { r#override, .. } => *r#override = true,
310            Self::Tag { r#override, .. } => *r#override = true,
311            Self::Rev { r#override, .. } => *r#override = true,
312        }
313    }
314
315    /// Returns whether the dependency has been overridden.
316    pub fn overridden(&self) -> bool {
317        match self {
318            Self::Branch { r#override, .. } => *r#override,
319            Self::Tag { r#override, .. } => *r#override,
320            Self::Rev { r#override, .. } => *r#override,
321        }
322    }
323
324    /// Returns whether the dependency is a branch.
325    pub fn is_branch(&self) -> bool {
326        matches!(self, Self::Branch { .. })
327    }
328}
329
330impl std::fmt::Display for DepIdentifier {
331    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
332        match self {
333            Self::Branch { name, rev, .. } => write!(f, "branch={name}@{rev}"),
334            Self::Tag { name, rev, .. } => write!(f, "tag={name}@{rev}"),
335            Self::Rev { rev, .. } => write!(f, "rev={rev}"),
336        }
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use std::fs;
344    use tempfile::tempdir;
345
346    #[test]
347    fn serde_dep_identifier() {
348        let branch = DepIdentifier::Branch {
349            name: "main".to_string(),
350            rev: "b7954c3e9ce1d487b49489f5800f52f4b77b7351".to_string(),
351            r#override: false,
352        };
353
354        let tag = DepIdentifier::Tag {
355            name: "v0.1.0".to_string(),
356            rev: "b7954c3e9ce1d487b49489f5800f52f4b77b7351".to_string(),
357            r#override: false,
358        };
359
360        let rev = DepIdentifier::Rev {
361            rev: "b7954c3e9ce1d487b49489f5800f52f4b77b7351".to_string(),
362            r#override: false,
363        };
364
365        let branch_str = serde_json::to_string(&branch).unwrap();
366        let tag_str = serde_json::to_string(&tag).unwrap();
367        let rev_str = serde_json::to_string(&rev).unwrap();
368
369        assert_eq!(
370            branch_str,
371            r#"{"branch":{"name":"main","rev":"b7954c3e9ce1d487b49489f5800f52f4b77b7351"}}"#
372        );
373        assert_eq!(
374            tag_str,
375            r#"{"tag":{"name":"v0.1.0","rev":"b7954c3e9ce1d487b49489f5800f52f4b77b7351"}}"#
376        );
377        assert_eq!(rev_str, r#"{"rev":"b7954c3e9ce1d487b49489f5800f52f4b77b7351"}"#);
378
379        let branch_de: DepIdentifier = serde_json::from_str(&branch_str).unwrap();
380        let tag_de: DepIdentifier = serde_json::from_str(&tag_str).unwrap();
381        let rev_de: DepIdentifier = serde_json::from_str(&rev_str).unwrap();
382
383        assert_eq!(branch, branch_de);
384        assert_eq!(tag, tag_de);
385        assert_eq!(rev, rev_de);
386    }
387
388    #[test]
389    fn test_write_ordered_deps() {
390        let dir = tempdir().unwrap();
391        let mut lockfile = Lockfile::new(dir.path());
392        lockfile.insert(
393            PathBuf::from("z_dep"),
394            DepIdentifier::Rev { rev: "3".to_string(), r#override: false },
395        );
396        lockfile.insert(
397            PathBuf::from("a_dep"),
398            DepIdentifier::Rev { rev: "1".to_string(), r#override: false },
399        );
400        lockfile.insert(
401            PathBuf::from("c_dep"),
402            DepIdentifier::Rev { rev: "2".to_string(), r#override: false },
403        );
404        let _ = lockfile.write();
405        let contents = fs::read_to_string(lockfile.lockfile_path).unwrap();
406        let expected = r#"{
407  "a_dep": {
408    "rev": "1"
409  },
410  "c_dep": {
411    "rev": "2"
412  },
413  "z_dep": {
414    "rev": "3"
415  }
416}"#;
417        assert_eq!(contents.trim(), expected.trim());
418
419        let mut lockfile = Lockfile::new(dir.path());
420        lockfile.read().unwrap();
421        lockfile.insert(
422            PathBuf::from("x_dep"),
423            DepIdentifier::Rev { rev: "4".to_string(), r#override: false },
424        );
425        let _ = lockfile.write();
426        let contents = fs::read_to_string(lockfile.lockfile_path).unwrap();
427        let expected = r#"{
428  "a_dep": {
429    "rev": "1"
430  },
431  "c_dep": {
432    "rev": "2"
433  },
434  "x_dep": {
435    "rev": "4"
436  },
437  "z_dep": {
438    "rev": "3"
439  }
440}"#;
441        assert_eq!(contents.trim(), expected.trim());
442    }
443}