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