1use alloy_primitives::map::HashMap;
4use eyre::{Context, OptionExt, Result};
5use foundry_cli::utils::{Git, SubmoduleCheckoutStatus};
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
14pub type DepMap = HashMap<PathBuf, DepIdentifier>;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub(crate) enum LockfileMismatch {
20 MissingLockEntry { path: PathBuf, actual: Option<String> },
22 MissingSubmodule { path: PathBuf, expected: String },
24 MissingSubmoduleMapping { path: PathBuf },
26 Uninitialized { path: PathBuf, expected: Option<String> },
28 Conflicted { path: PathBuf },
30 Revision { path: PathBuf, expected: String, actual: String },
32}
33
34impl LockfileMismatch {
35 pub fn path(&self) -> &Path {
37 match self {
38 Self::MissingLockEntry { path, .. }
39 | Self::MissingSubmodule { path, .. }
40 | Self::MissingSubmoduleMapping { path }
41 | Self::Uninitialized { path, .. }
42 | Self::Conflicted { path }
43 | Self::Revision { path, .. } => path,
44 }
45 }
46}
47
48impl std::fmt::Display for LockfileMismatch {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 match self {
51 Self::MissingLockEntry { path, actual: Some(actual) } => {
52 write!(f, "{}: missing from foundry.lock (found {actual})", path.display())
53 }
54 Self::MissingLockEntry { path, actual: None } => {
55 write!(f, "{}: missing from foundry.lock", path.display())
56 }
57 Self::MissingSubmodule { path, expected } => write!(
58 f,
59 "{}: dependency submodule is missing (expected {expected})",
60 path.display()
61 ),
62 Self::MissingSubmoduleMapping { path } => {
63 write!(f, "{}: dependency submodule is missing from .gitmodules", path.display())
64 }
65 Self::Uninitialized { path, expected: Some(expected) } => write!(
66 f,
67 "{}: dependency submodule is not initialized (expected {expected})",
68 path.display()
69 ),
70 Self::Uninitialized { path, expected: None } => {
71 write!(f, "{}: dependency submodule is not initialized", path.display())
72 }
73 Self::Conflicted { path } => {
74 write!(f, "{}: dependency submodule has merge conflicts", path.display())
75 }
76 Self::Revision { path, expected, actual } => {
77 write!(f, "{}: expected {expected}, found {actual}", path.display())
78 }
79 }
80 }
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct Lockfile<'a> {
86 #[serde(flatten)]
88 deps: DepMap,
89 #[serde(skip)]
91 git: Option<&'a Git<'a>>,
92 #[serde(skip)]
94 lockfile_path: PathBuf,
95}
96
97impl<'a> Lockfile<'a> {
98 pub fn new(project_root: &Path) -> Self {
104 Self { deps: HashMap::default(), git: None, lockfile_path: project_root.join(FOUNDRY_LOCK) }
105 }
106
107 pub const fn with_git(mut self, git: &'a Git<'_>) -> Self {
109 self.git = Some(git);
110 self
111 }
112
113 pub fn sync(&mut self, lib: &Path) -> Result<Option<DepMap>> {
122 match self.read() {
123 Ok(_) => {}
124 Err(e) if !e.to_string().contains("Lockfile not found") => {
125 return Err(e);
126 }
127 _ => {}
128 }
129
130 if let Some(git) = &self.git {
131 let submodules = git.submodules()?;
132
133 if submodules.is_empty() {
134 trace!("No submodules found. Skipping sync.");
135 return Ok(None);
136 }
137
138 let modules_with_branch = git
139 .read_submodules_with_branch(&Git::root_of(git.root)?, lib.file_name().unwrap())?;
140
141 let mut out_of_sync: DepMap = HashMap::default();
142 for sub in &submodules {
143 let rel_path = sub.path();
144 let rev = sub.rev();
145
146 let entry = self.deps.entry(rel_path.clone());
147
148 match entry {
149 Entry::Occupied(e) if e.get().rev() != rev => {
150 out_of_sync.insert(rel_path.clone(), e.get().clone());
151 }
152 Entry::Vacant(e) => {
153 let maybe_branch = modules_with_branch.get(rel_path).cloned();
156
157 trace!(?maybe_branch, submodule = ?rel_path, "submodule branch");
158 if let Some(branch) = maybe_branch {
159 let dep_id = DepIdentifier::Branch {
160 name: branch,
161 rev: rev.to_string(),
162 r#override: false,
163 };
164 e.insert(dep_id.clone());
165 out_of_sync.insert(rel_path.clone(), dep_id);
166 continue;
167 }
168
169 let dep_id = DepIdentifier::Rev { rev: rev.to_string(), r#override: false };
170 trace!(submodule=?rel_path, ?dep_id, "submodule dep_id");
171 e.insert(dep_id.clone());
172 out_of_sync.insert(rel_path.clone(), dep_id);
173 }
174 _ => {}
175 }
176 }
177
178 return Ok(if out_of_sync.is_empty() { None } else { Some(out_of_sync) });
179 }
180
181 Ok(None)
182 }
183
184 pub(crate) fn check(&mut self) -> Result<Vec<LockfileMismatch>> {
186 let lockfile_exists = self.exists();
187 if lockfile_exists {
188 self.read().wrap_err("Failed to read foundry.lock")?;
189 } else {
190 self.deps.clear();
191 }
192
193 let git = self.git.ok_or_eyre("Git is required to check foundry.lock")?;
194 let project_root =
195 dunce::canonicalize(self.lockfile_path.parent().expect("lockfile path has a parent"))?;
196 let git_root = match Git::root_of(git.root) {
197 Ok(root) => root,
198 Err(_)
199 if !lockfile_exists
200 && !project_root.ancestors().any(|root| root.join(".git").exists()) =>
201 {
202 return Ok(Vec::new());
203 }
204 Err(err) => return Err(err),
205 };
206 let project_prefix = project_root.strip_prefix(&git_root).map_err(|_| {
207 eyre::eyre!("Project root is not contained in Git root {}", git_root.display())
208 })?;
209
210 let repository_path = relative_path(&git_root, &project_root);
211 let git_submodules =
212 git.submodules_in_worktree(&repository_path, &git_root, project_prefix)?;
213 let mut submodules = BTreeMap::new();
214 for submodule in &git_submodules {
215 submodules.insert(submodule.path().clone(), submodule);
216 }
217
218 let mut mismatches = Vec::new();
219 for (path, dep) in &self.deps {
220 let expected = dep.rev();
221 let Some(submodule) = submodules.remove(path) else {
222 mismatches.push(LockfileMismatch::MissingSubmodule {
223 path: path.clone(),
224 expected: expected.to_string(),
225 });
226 continue;
227 };
228 match submodule.status() {
229 SubmoduleCheckoutStatus::Uninitialized => {
230 mismatches.push(LockfileMismatch::Uninitialized {
231 path: path.clone(),
232 expected: Some(expected.to_string()),
233 });
234 }
235 SubmoduleCheckoutStatus::Conflicted => {
236 mismatches.push(LockfileMismatch::Conflicted { path: path.clone() });
237 }
238 SubmoduleCheckoutStatus::MissingMapping => {
239 mismatches
240 .push(LockfileMismatch::MissingSubmoduleMapping { path: path.clone() });
241 }
242 SubmoduleCheckoutStatus::Current | SubmoduleCheckoutStatus::Modified
243 if submodule.rev() != expected =>
244 {
245 mismatches.push(LockfileMismatch::Revision {
246 path: path.clone(),
247 expected: expected.to_string(),
248 actual: submodule.rev().to_string(),
249 });
250 }
251 SubmoduleCheckoutStatus::Current | SubmoduleCheckoutStatus::Modified => {}
252 }
253 }
254 for (path, submodule) in submodules {
255 match submodule.status() {
256 SubmoduleCheckoutStatus::MissingMapping => {
257 mismatches.push(LockfileMismatch::MissingSubmoduleMapping { path });
258 }
259 SubmoduleCheckoutStatus::Uninitialized => {
260 mismatches.push(LockfileMismatch::MissingLockEntry {
261 path: path.clone(),
262 actual: None,
263 });
264 mismatches.push(LockfileMismatch::Uninitialized { path, expected: None });
265 }
266 SubmoduleCheckoutStatus::Conflicted => {
267 mismatches.push(LockfileMismatch::MissingLockEntry {
268 path: path.clone(),
269 actual: None,
270 });
271 mismatches.push(LockfileMismatch::Conflicted { path });
272 }
273 SubmoduleCheckoutStatus::Current | SubmoduleCheckoutStatus::Modified => {
274 mismatches.push(LockfileMismatch::MissingLockEntry {
275 path,
276 actual: Some(submodule.rev().to_string()),
277 });
278 }
279 }
280 }
281 mismatches.sort_by(|a, b| a.path().cmp(b.path()));
282 Ok(mismatches)
283 }
284
285 pub fn read(&mut self) -> Result<()> {
289 if !self.lockfile_path.exists() {
290 return Err(eyre::eyre!("Lockfile not found at {}", self.lockfile_path.display()));
291 }
292
293 let lockfile_str = foundry_common::fs::read_to_string(&self.lockfile_path)?;
294
295 self.deps = serde_json::from_str(&lockfile_str)?;
296
297 trace!(lockfile = ?self.deps, "loaded lockfile");
298
299 Ok(())
300 }
301
302 pub fn write(&self) -> Result<()> {
304 let ordered_deps: BTreeMap<_, _> = self.deps.clone().into_iter().collect();
305 foundry_common::fs::write_pretty_json_file(&self.lockfile_path, &ordered_deps)?;
306 trace!(at= ?self.lockfile_path, "wrote lockfile");
307
308 Ok(())
309 }
310
311 pub fn insert(&mut self, path: PathBuf, dep_id: DepIdentifier) {
316 self.deps.insert(path, dep_id);
317 }
318
319 pub fn get(&self, path: &Path) -> Option<&DepIdentifier> {
321 self.deps.get(path)
322 }
323
324 pub fn remove(&mut self, path: &Path) -> Option<DepIdentifier> {
328 self.deps.remove(path)
329 }
330
331 pub fn override_dep(
338 &mut self,
339 dep: &Path,
340 mut new_dep_id: DepIdentifier,
341 ) -> Result<DepIdentifier> {
342 let prev = self
343 .deps
344 .get_mut(dep)
345 .map(|d| {
346 new_dep_id.mark_override();
347 std::mem::replace(d, new_dep_id)
348 })
349 .ok_or_eyre(format!("Dependency not found in lockfile: {}", dep.display()))?;
350
351 Ok(prev)
352 }
353
354 pub fn len(&self) -> usize {
356 self.deps.len()
357 }
358
359 pub fn is_empty(&self) -> bool {
361 self.deps.is_empty()
362 }
363
364 pub fn iter(&self) -> impl Iterator<Item = (&PathBuf, &DepIdentifier)> {
366 self.deps.iter()
367 }
368
369 pub fn iter_mut(&mut self) -> impl Iterator<Item = (&PathBuf, &mut DepIdentifier)> {
371 self.deps.iter_mut()
372 }
373
374 pub fn exists(&self) -> bool {
375 self.lockfile_path.exists()
376 }
377}
378
379fn relative_path(path: &Path, base: &Path) -> PathBuf {
380 let common =
381 path.components().zip(base.components()).take_while(|(path, base)| path == base).count();
382 let mut relative = PathBuf::new();
383 for _ in common..base.components().count() {
384 relative.push("..");
385 }
386 relative.extend(path.components().skip(common));
387 relative
388}
389
390#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
398pub enum DepIdentifier {
399 #[serde(rename = "branch")]
402 Branch {
403 name: String,
404 rev: String,
405 #[serde(skip)]
406 r#override: bool,
407 },
408 #[serde(rename = "tag")]
413 Tag {
414 name: String,
415 rev: String,
416 #[serde(skip)]
417 r#override: bool,
418 },
419 #[serde(rename = "rev", untagged)]
423 Rev {
424 rev: String,
425 #[serde(skip)]
426 r#override: bool,
427 },
428}
429
430impl DepIdentifier {
431 pub fn resolve_type(git: &Git<'_>, lib_path: &Path, s: &str) -> Result<Self> {
434 trace!(lib_path = ?lib_path, resolving_type = ?s, "resolving submodule identifier");
435 if git.has_tag(s, lib_path)? {
437 let rev = git.get_rev(s, lib_path)?;
438 return Ok(Self::Tag { name: String::from(s), rev, r#override: false });
439 }
440
441 if git.has_branch(s, lib_path)? {
442 let rev = git.get_rev(s, lib_path)?;
443 return Ok(Self::Branch { name: String::from(s), rev, r#override: false });
444 }
445
446 if git.has_rev(s, lib_path)? {
447 return Ok(Self::Rev { rev: String::from(s), r#override: false });
448 }
449
450 Err(eyre::eyre!("Could not resolve tag type for submodule at path {}", lib_path.display()))
451 }
452
453 pub fn rev(&self) -> &str {
455 match self {
456 Self::Branch { rev, .. } => rev,
457 Self::Tag { rev, .. } => rev,
458 Self::Rev { rev, .. } => rev,
459 }
460 }
461
462 pub fn name(&self) -> &str {
466 match self {
467 Self::Branch { name, .. } => name,
468 Self::Tag { name, .. } => name,
469 Self::Rev { rev, .. } => rev,
470 }
471 }
472
473 pub fn checkout_id(&self) -> &str {
475 match self {
476 Self::Branch { name, .. } => name,
477 Self::Tag { name, .. } => name,
478 Self::Rev { rev, .. } => rev,
479 }
480 }
481
482 pub const fn mark_override(&mut self) {
484 match self {
485 Self::Branch { r#override, .. } => *r#override = true,
486 Self::Tag { r#override, .. } => *r#override = true,
487 Self::Rev { r#override, .. } => *r#override = true,
488 }
489 }
490
491 pub const fn overridden(&self) -> bool {
493 match self {
494 Self::Branch { r#override, .. } => *r#override,
495 Self::Tag { r#override, .. } => *r#override,
496 Self::Rev { r#override, .. } => *r#override,
497 }
498 }
499
500 pub const fn is_branch(&self) -> bool {
502 matches!(self, Self::Branch { .. })
503 }
504}
505
506impl std::fmt::Display for DepIdentifier {
507 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
508 match self {
509 Self::Branch { name, rev, .. } => write!(f, "branch={name}@{rev}"),
510 Self::Tag { name, rev, .. } => write!(f, "tag={name}@{rev}"),
511 Self::Rev { rev, .. } => write!(f, "rev={rev}"),
512 }
513 }
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519 use std::fs;
520 use tempfile::tempdir;
521
522 #[test]
523 fn serde_dep_identifier() {
524 let branch = DepIdentifier::Branch {
525 name: "main".to_string(),
526 rev: "b7954c3e9ce1d487b49489f5800f52f4b77b7351".to_string(),
527 r#override: false,
528 };
529
530 let tag = DepIdentifier::Tag {
531 name: "v0.1.0".to_string(),
532 rev: "b7954c3e9ce1d487b49489f5800f52f4b77b7351".to_string(),
533 r#override: false,
534 };
535
536 let rev = DepIdentifier::Rev {
537 rev: "b7954c3e9ce1d487b49489f5800f52f4b77b7351".to_string(),
538 r#override: false,
539 };
540
541 let branch_str = serde_json::to_string(&branch).unwrap();
542 let tag_str = serde_json::to_string(&tag).unwrap();
543 let rev_str = serde_json::to_string(&rev).unwrap();
544
545 assert_eq!(
546 branch_str,
547 r#"{"branch":{"name":"main","rev":"b7954c3e9ce1d487b49489f5800f52f4b77b7351"}}"#
548 );
549 assert_eq!(
550 tag_str,
551 r#"{"tag":{"name":"v0.1.0","rev":"b7954c3e9ce1d487b49489f5800f52f4b77b7351"}}"#
552 );
553 assert_eq!(rev_str, r#"{"rev":"b7954c3e9ce1d487b49489f5800f52f4b77b7351"}"#);
554
555 let branch_de: DepIdentifier = serde_json::from_str(&branch_str).unwrap();
556 let tag_de: DepIdentifier = serde_json::from_str(&tag_str).unwrap();
557 let rev_de: DepIdentifier = serde_json::from_str(&rev_str).unwrap();
558
559 assert_eq!(branch, branch_de);
560 assert_eq!(tag, tag_de);
561 assert_eq!(rev, rev_de);
562 }
563
564 #[test]
565 fn test_write_ordered_deps() {
566 let dir = tempdir().unwrap();
567 let mut lockfile = Lockfile::new(dir.path());
568 lockfile.insert(
569 PathBuf::from("z_dep"),
570 DepIdentifier::Rev { rev: "3".to_string(), r#override: false },
571 );
572 lockfile.insert(
573 PathBuf::from("a_dep"),
574 DepIdentifier::Rev { rev: "1".to_string(), r#override: false },
575 );
576 lockfile.insert(
577 PathBuf::from("c_dep"),
578 DepIdentifier::Rev { rev: "2".to_string(), r#override: false },
579 );
580 let _ = lockfile.write();
581 let contents = fs::read_to_string(lockfile.lockfile_path).unwrap();
582 let expected = r#"{
583 "a_dep": {
584 "rev": "1"
585 },
586 "c_dep": {
587 "rev": "2"
588 },
589 "z_dep": {
590 "rev": "3"
591 }
592}"#;
593 assert_eq!(contents.trim(), expected.trim());
594
595 let mut lockfile = Lockfile::new(dir.path());
596 lockfile.read().unwrap();
597 lockfile.insert(
598 PathBuf::from("x_dep"),
599 DepIdentifier::Rev { rev: "4".to_string(), r#override: false },
600 );
601 let _ = lockfile.write();
602 let contents = fs::read_to_string(lockfile.lockfile_path).unwrap();
603 let expected = r#"{
604 "a_dep": {
605 "rev": "1"
606 },
607 "c_dep": {
608 "rev": "2"
609 },
610 "x_dep": {
611 "rev": "4"
612 },
613 "z_dep": {
614 "rev": "3"
615 }
616}"#;
617 assert_eq!(contents.trim(), expected.trim());
618 }
619}