Skip to main content

soldeer_core/
lock.rs

1//! Lockfile handling.
2//!
3//! The lockfile contains the resolved dependencies of a project. It is a TOML file with an array of
4//! dependencies, each containing the name, version, and other information about the dependency.
5//!
6//! The lockfile is used to ensure that the same versions of dependencies are installed across
7//! different machines. It is also used to skip the installation of dependencies that are already
8//! installed.
9use crate::{config::Dependency, errors::LockError, utils::sanitize_filename};
10use log::{debug, warn};
11use serde::{Deserialize, Serialize};
12use std::{
13    fs,
14    path::{Path, PathBuf},
15};
16
17pub mod forge;
18
19pub const SOLDEER_LOCK: &str = "soldeer.lock";
20
21pub type Result<T> = std::result::Result<T, LockError>;
22
23/// A trait implemented by lockfile entries to provide the install path
24pub trait Integrity {
25    /// Returns the install path of the dependency.
26    fn install_path(&self, deps: impl AsRef<Path>) -> PathBuf;
27
28    /// Returns the integrity checksum if relevant.
29    fn integrity(&self) -> Option<&String>;
30}
31
32/// A lock entry for a git dependency.
33#[derive(Debug, Clone, PartialEq, Eq, Hash, bon::Builder)]
34#[builder(on(String, into))]
35#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
36#[non_exhaustive]
37pub struct GitLockEntry {
38    /// The name of the dependency.
39    pub name: String,
40
41    /// The version (this corresponds to the version requirement of the dependency).
42    pub version: String,
43
44    /// The git url of the dependency.
45    pub git: String,
46
47    /// The resolved git commit hash.
48    pub rev: String,
49}
50
51impl Integrity for GitLockEntry {
52    /// Returns the install path of the dependency.
53    ///
54    /// The directory does not need to exist. Since the lock entry contains the version,
55    /// the install path can be calculated without needing to check the actual directory.
56    fn install_path(&self, deps: impl AsRef<Path>) -> PathBuf {
57        format_install_path(&self.name, &self.version, deps)
58    }
59
60    /// There is no integrity checksum for git lock entries
61    fn integrity(&self) -> Option<&String> {
62        None
63    }
64}
65
66/// A lock entry for an HTTP dependency.
67#[derive(Debug, Clone, PartialEq, Eq, Hash, bon::Builder)]
68#[builder(on(String, into))]
69#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
70#[non_exhaustive]
71pub struct HttpLockEntry {
72    /// The name of the dependency.
73    pub name: String,
74
75    /// The resolved version of the dependency (not necessarily matches the version requirement of
76    /// the dependency).
77    ///
78    /// If the version req is a semver range, then this will be the exact version that was
79    /// resolved.
80    pub version: String,
81
82    /// The URL from where the dependency was downloaded.
83    pub url: String,
84
85    /// The checksum of the downloaded zip file.
86    pub checksum: String,
87
88    /// The integrity hash of the downloaded zip file after extraction.
89    pub integrity: String,
90}
91
92impl Integrity for HttpLockEntry {
93    /// Returns the install path of the dependency.
94    ///
95    /// The directory does not need to exist. Since the lock entry contains the version,
96    /// the install path can be calculated without needing to check the actual directory.
97    fn install_path(&self, deps: impl AsRef<Path>) -> PathBuf {
98        format_install_path(&self.name, &self.version, deps)
99    }
100
101    /// Returns the integrity checksum
102    fn integrity(&self) -> Option<&String> {
103        Some(&self.integrity)
104    }
105}
106
107/// A lock entry for a private dependency.
108///
109/// The link is not stored in the lockfile as it must be fetched from the registry with a valid
110/// token before each download.
111#[derive(Debug, Clone, PartialEq, Eq, Hash, bon::Builder)]
112#[builder(on(String, into))]
113#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
114#[non_exhaustive]
115pub struct PrivateLockEntry {
116    /// The name of the dependency.
117    pub name: String,
118
119    /// The resolved version of the dependency (not necessarily matches the version requirement of
120    /// the dependency).
121    ///
122    /// If the version req is a semver range, then this will be the exact version that was
123    /// resolved.
124    pub version: String,
125
126    /// The checksum of the downloaded zip file.
127    pub checksum: String,
128
129    /// The integrity hash of the downloaded zip file after extraction.
130    pub integrity: String,
131}
132
133impl Integrity for PrivateLockEntry {
134    /// Returns the install path of the dependency.
135    ///
136    /// The directory does not need to exist. Since the lock entry contains the version,
137    /// the install path can be calculated without needing to check the actual directory.
138    fn install_path(&self, deps: impl AsRef<Path>) -> PathBuf {
139        format_install_path(&self.name, &self.version, deps)
140    }
141
142    /// Returns the integrity checksum
143    fn integrity(&self) -> Option<&String> {
144        Some(&self.integrity)
145    }
146}
147
148/// A lock entry for a dependency.
149///
150/// A builder should be used to create the underlying [`HttpLockEntry`] or [`GitLockEntry`] and then
151/// converted into this type with `.into()`.
152///
153/// # Examples
154///
155/// ```
156/// # use soldeer_core::lock::{LockEntry, HttpLockEntry};
157/// let dep: LockEntry = HttpLockEntry::builder()
158///     .name("my-dep")
159///     .version("1.2.3")
160///     .url("https://...")
161///     .checksum("dead")
162///     .integrity("beef")
163///     .build()
164///     .into();
165/// ```
166#[derive(Debug, Clone, PartialEq, Eq, Hash)]
167#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
168#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
169#[non_exhaustive]
170pub enum LockEntry {
171    /// A lock entry for an HTTP dependency.
172    Http(HttpLockEntry),
173
174    /// A lock entry for a git dependency.
175    Git(GitLockEntry),
176
177    /// A lock entry for a git dependency.
178    Private(PrivateLockEntry),
179}
180
181/// A TOML representation of a lock entry, which merges all fields from the two variants of
182/// [`LockEntry`].
183///
184/// This is used to serialize and deserialize lock entries to and from TOML. All fields which are
185/// not present in both variants are optional.
186#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
187#[non_exhaustive]
188pub struct TomlLockEntry {
189    pub name: String,
190    pub version: String,
191    pub git: Option<String>,
192    pub url: Option<String>,
193    pub rev: Option<String>,
194    pub checksum: Option<String>,
195    pub integrity: Option<String>,
196}
197
198impl From<LockEntry> for TomlLockEntry {
199    /// Convert a [`LockEntry`] into a [`TomlLockEntry`].
200    fn from(value: LockEntry) -> Self {
201        match value {
202            LockEntry::Http(lock) => Self {
203                name: lock.name,
204                version: lock.version,
205                git: None,
206                url: Some(lock.url),
207                rev: None,
208                checksum: Some(lock.checksum),
209                integrity: Some(lock.integrity),
210            },
211            LockEntry::Git(lock) => Self {
212                name: lock.name,
213                version: lock.version,
214                git: Some(lock.git),
215                url: None,
216                rev: Some(lock.rev),
217                checksum: None,
218                integrity: None,
219            },
220            LockEntry::Private(lock) => Self {
221                name: lock.name,
222                version: lock.version,
223                git: None,
224                url: None,
225                rev: None,
226                checksum: Some(lock.checksum),
227                integrity: Some(lock.integrity),
228            },
229        }
230    }
231}
232
233impl TryFrom<TomlLockEntry> for LockEntry {
234    type Error = LockError;
235
236    /// Convert a [`TomlLockEntry`] into a [`LockEntry`] if possible.
237    fn try_from(value: TomlLockEntry) -> std::result::Result<Self, Self::Error> {
238        match (value.url, value.git) {
239            (None, None) => Ok(PrivateLockEntry::builder()
240                .name(&value.name)
241                .version(value.version)
242                .checksum(value.checksum.ok_or(LockError::MissingField {
243                    field: "checksum".to_string(),
244                    dep: value.name.clone(),
245                })?)
246                .integrity(value.integrity.ok_or(LockError::MissingField {
247                    field: "integrity".to_string(),
248                    dep: value.name,
249                })?)
250                .build()
251                .into()),
252            (None, Some(git)) => {
253                Ok(GitLockEntry::builder()
254                    .name(&value.name)
255                    .version(value.version)
256                    .git(git)
257                    .rev(value.rev.ok_or(LockError::MissingField {
258                        field: "rev".to_string(),
259                        dep: value.name,
260                    })?)
261                    .build()
262                    .into())
263            }
264            (Some(url), None) => Ok(HttpLockEntry::builder()
265                .name(&value.name)
266                .version(value.version)
267                .url(url)
268                .checksum(value.checksum.ok_or(LockError::MissingField {
269                    field: "checksum".to_string(),
270                    dep: value.name.clone(),
271                })?)
272                .integrity(value.integrity.ok_or(LockError::MissingField {
273                    field: "integrity".to_string(),
274                    dep: value.name,
275                })?)
276                .build()
277                .into()),
278            (Some(_), Some(_)) => Err(LockError::InvalidLockEntry),
279        }
280    }
281}
282
283impl LockEntry {
284    /// The name of the dependency.
285    pub fn name(&self) -> &str {
286        match self {
287            Self::Git(lock) => &lock.name,
288            Self::Http(lock) => &lock.name,
289            Self::Private(lock) => &lock.name,
290        }
291    }
292
293    /// The version of the dependency.
294    pub fn version(&self) -> &str {
295        match self {
296            Self::Git(lock) => &lock.version,
297            Self::Http(lock) => &lock.version,
298            Self::Private(lock) => &lock.version,
299        }
300    }
301
302    /// The install path of the dependency.
303    pub fn install_path(&self, deps: impl AsRef<Path>) -> PathBuf {
304        match self {
305            Self::Git(lock) => lock.install_path(deps),
306            Self::Http(lock) => lock.install_path(deps),
307            Self::Private(lock) => lock.install_path(deps),
308        }
309    }
310
311    /// Get the underlying [`HttpLockEntry`] if this is an HTTP lock entry.
312    pub fn as_http(&self) -> Option<&HttpLockEntry> {
313        if let Self::Http(l) = self { Some(l) } else { None }
314    }
315
316    /// Get the underlying [`GitLockEntry`] if this is a git lock entry.
317    pub fn as_git(&self) -> Option<&GitLockEntry> {
318        if let Self::Git(l) = self { Some(l) } else { None }
319    }
320
321    /// Get the underlying [`PrivateLockEntry`] if this is a private package lock entry.
322    pub fn as_private(&self) -> Option<&PrivateLockEntry> {
323        if let Self::Private(l) = self { Some(l) } else { None }
324    }
325}
326
327impl From<HttpLockEntry> for LockEntry {
328    /// Wrap an [`HttpLockEntry`] in a [`LockEntry`].
329    fn from(value: HttpLockEntry) -> Self {
330        Self::Http(value)
331    }
332}
333
334impl From<GitLockEntry> for LockEntry {
335    /// Wrap a [`GitLockEntry`] in a [`LockEntry`].
336    fn from(value: GitLockEntry) -> Self {
337        Self::Git(value)
338    }
339}
340
341impl From<PrivateLockEntry> for LockEntry {
342    /// Wrap a [`PrivateLockEntry`] in a [`LockEntry`].
343    fn from(value: PrivateLockEntry) -> Self {
344        Self::Private(value)
345    }
346}
347
348/// A parsed TOML lock file.
349///
350/// The lockfile is a table with one entry `dependencies` containing an array of [`TomlLockEntry`]s.
351#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, Hash)]
352struct LockFileParsed {
353    dependencies: Vec<TomlLockEntry>,
354}
355
356/// The result of reading and parsing a lock file.
357///
358/// The [`TomlLockEntry`]s are converted into [`LockEntry`]s. A copy of the text contents of
359/// the lockfile is provided for diffing purposes.
360#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
361#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
362pub struct LockFile {
363    /// The parsed lock entries.
364    pub entries: Vec<LockEntry>,
365
366    /// The raw contents of the lockfile.
367    pub raw: String,
368}
369
370/// Read a lockfile from disk.
371pub fn read_lockfile(path: impl AsRef<Path>) -> Result<LockFile> {
372    if !path.as_ref().exists() {
373        debug!(path:? = path.as_ref(); "lockfile does not exist");
374        return Ok(LockFile::default());
375    }
376    let contents = fs::read_to_string(&path)?;
377
378    let data: LockFileParsed = toml_edit::de::from_str(&contents)
379        .inspect_err(|err| {
380            warn!(err:?; "error while parsing lockfile contents, it will be ignored");
381        })
382        .unwrap_or_default();
383    Ok(LockFile {
384        entries: data.dependencies.into_iter().filter_map(|d| d.try_into().ok()).collect(),
385        raw: contents,
386    })
387}
388
389/// Generate the contents of a lockfile from a list of lock entries.
390///
391/// The entries do not need to be sorted, they will be sorted by name.
392pub fn generate_lockfile_contents(mut entries: Vec<LockEntry>) -> String {
393    entries.sort_unstable_by(|a, b| a.name().cmp(b.name()));
394    let data = LockFileParsed { dependencies: entries.into_iter().map(Into::into).collect() };
395    toml_edit::ser::to_string_pretty(&data).expect("Lock entries should be serializable")
396}
397
398/// Add a lock entry to a lockfile.
399///
400/// If an entry with the same name already exists, it will be replaced.
401/// The entries are sorted by name before being written back to the file.
402pub fn add_to_lockfile(entry: LockEntry, path: impl AsRef<Path>) -> Result<()> {
403    let mut lockfile = read_lockfile(&path)?;
404    if let Some(index) = lockfile.entries.iter().position(|e| e.name() == entry.name()) {
405        debug!(name = entry.name(); "replacing existing lockfile entry");
406        let _ = std::mem::replace(&mut lockfile.entries[index], entry);
407    } else {
408        debug!(name = entry.name(); "adding new lockfile entry");
409        lockfile.entries.push(entry);
410    }
411    let new_contents = generate_lockfile_contents(lockfile.entries);
412    fs::write(&path, new_contents)?;
413    debug!(path:? = path.as_ref(); "lockfile modified");
414    Ok(())
415}
416
417/// Remove a lock entry from a lockfile, matching on the name.
418///
419/// If the entry is the last entry in the lockfile, the lockfile will be removed.
420pub fn remove_lock(dependency: &Dependency, path: impl AsRef<Path>) -> Result<()> {
421    let lockfile = read_lockfile(&path)?;
422
423    let entries: Vec<_> = lockfile
424        .entries
425        .into_iter()
426        .filter_map(|e| if e.name() != dependency.name() { Some(e.into()) } else { None })
427        .collect();
428
429    if entries.is_empty() {
430        // remove lock file if there are no deps left
431        debug!(path:? = path.as_ref(); "no remaining lockfile entry, deleting file");
432        let _ = fs::remove_file(&path);
433        return Ok(());
434    }
435
436    let file_contents =
437        toml_edit::ser::to_string_pretty(&LockFileParsed { dependencies: entries })?;
438
439    // replace contents of lockfile with new contents
440    fs::write(&path, file_contents)?;
441    debug!(path:? = path.as_ref(); "lockfile modified");
442    Ok(())
443}
444
445/// Format the install path of a dependency.
446///
447/// The folder name is sanitized to remove disallowed characters.
448pub fn format_install_path(name: &str, version: &str, deps: impl AsRef<Path>) -> PathBuf {
449    deps.as_ref().join(sanitize_filename(&format!("{name}-{version}")))
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455    use testdir::testdir;
456
457    #[test]
458    fn test_toml_to_lock_entry_conversion_http() {
459        let toml_entry = TomlLockEntry {
460            name: "test".to_string(),
461            version: "1.0.0".to_string(),
462            git: None,
463            url: Some("https://example.com/zip.zip".to_string()),
464            rev: None,
465            checksum: Some("123456".to_string()),
466            integrity: Some("beef".to_string()),
467        };
468        let entry: Result<LockEntry> = toml_entry.try_into();
469        assert!(entry.is_ok(), "{entry:?}");
470        let entry = entry.unwrap();
471        assert_eq!(entry.name(), "test");
472        assert_eq!(entry.version(), "1.0.0");
473        let http = entry.as_http().unwrap();
474        assert_eq!(http.url, "https://example.com/zip.zip");
475        assert_eq!(http.checksum, "123456");
476        assert_eq!(http.integrity, "beef");
477    }
478
479    #[test]
480    fn test_toml_to_lock_entry_conversion_git() {
481        let toml_entry = TomlLockEntry {
482            name: "test".to_string(),
483            version: "1.0.0".to_string(),
484            git: Some("git@github.com:test/test.git".to_string()),
485            url: None,
486            rev: Some("123456".to_string()),
487            checksum: None,
488            integrity: None,
489        };
490        let entry: Result<LockEntry> = toml_entry.try_into();
491        assert!(entry.is_ok(), "{entry:?}");
492        let entry = entry.unwrap();
493        assert_eq!(entry.name(), "test");
494        assert_eq!(entry.version(), "1.0.0");
495        let git = entry.as_git().unwrap();
496        assert_eq!(git.git, "git@github.com:test/test.git");
497        assert_eq!(git.rev, "123456");
498    }
499
500    #[test]
501    fn test_toml_lock_entry_bad_http() {
502        let toml_entry = TomlLockEntry {
503            name: "test".to_string(),
504            version: "1.0.0".to_string(),
505            git: None,
506            url: Some("https://example.com/zip.zip".to_string()),
507            rev: None,
508            checksum: None,
509            integrity: None,
510        };
511        let entry: Result<LockEntry> = toml_entry.try_into();
512        assert!(
513            matches!(entry, Err(LockError::MissingField { ref field, dep: _ }) if field == "checksum"),
514            "{entry:?}"
515        );
516
517        let toml_entry = TomlLockEntry {
518            name: "test".to_string(),
519            version: "1.0.0".to_string(),
520            git: None,
521            url: Some("https://example.com/zip.zip".to_string()),
522            rev: None,
523            checksum: Some("123456".to_string()),
524            integrity: None,
525        };
526        let entry: Result<LockEntry> = toml_entry.try_into();
527        assert!(
528            matches!(entry, Err(LockError::MissingField { ref field, dep: _ }) if field == "integrity"),
529            "{entry:?}"
530        );
531    }
532
533    #[test]
534    fn test_toml_lock_entry_bad_private() {
535        let toml_entry = TomlLockEntry {
536            name: "test".to_string(),
537            version: "1.0.0".to_string(),
538            git: None,
539            url: None,
540            rev: None,
541            checksum: None,
542            integrity: None,
543        };
544        let entry: Result<LockEntry> = toml_entry.try_into();
545        assert!(
546            matches!(entry, Err(LockError::MissingField { ref field, dep: _ }) if field == "checksum"),
547            "{entry:?}"
548        );
549    }
550
551    #[test]
552    fn test_toml_lock_entry_bad_git() {
553        let toml_entry = TomlLockEntry {
554            name: "test".to_string(),
555            version: "1.0.0".to_string(),
556            git: Some("git@github.com:test/test.git".to_string()),
557            url: Some("https://example.com/zip.zip".to_string()),
558            rev: None,
559            checksum: None,
560            integrity: None,
561        };
562        let entry: Result<LockEntry> = toml_entry.try_into();
563        assert!(matches!(entry, Err(LockError::InvalidLockEntry)), "{entry:?}");
564
565        let toml_entry = TomlLockEntry {
566            name: "test".to_string(),
567            version: "1.0.0".to_string(),
568            git: Some("git@github.com:test/test.git".to_string()),
569            url: None,
570            rev: None,
571            checksum: None,
572            integrity: None,
573        };
574        let entry: Result<LockEntry> = toml_entry.try_into();
575        assert!(
576            matches!(entry, Err(LockError::MissingField { ref field, dep: _ }) if field == "rev"),
577            "{entry:?}"
578        );
579    }
580
581    #[test]
582    fn test_read_lockfile() {
583        let dir = testdir!();
584        let file_path = dir.join(SOLDEER_LOCK);
585        // last entry is invalid and should be skipped
586        let content = r#"[[dependencies]]
587name = "test"
588version = "1.0.0"
589git = "git@github.com:test/test.git"
590rev = "123456"
591
592[[dependencies]]
593name = "test2"
594version = "1.0.0"
595url = "https://example.com/zip.zip"
596checksum = "123456"
597integrity = "beef"
598
599[[dependencies]]
600name = "test3"
601version = "1.0.0"
602"#;
603        fs::write(&file_path, content).unwrap();
604        let res = read_lockfile(&file_path);
605        assert!(res.is_ok(), "{res:?}");
606        let lockfile = res.unwrap();
607        assert_eq!(lockfile.entries.len(), 2);
608        assert_eq!(lockfile.entries[0].name(), "test");
609        assert_eq!(lockfile.entries[0].version(), "1.0.0");
610        let git = lockfile.entries[0].as_git().unwrap();
611        assert_eq!(git.git, "git@github.com:test/test.git");
612        assert_eq!(git.rev, "123456");
613        assert_eq!(lockfile.entries[1].name(), "test2");
614        assert_eq!(lockfile.entries[1].version(), "1.0.0");
615        let http = lockfile.entries[1].as_http().unwrap();
616        assert_eq!(http.url, "https://example.com/zip.zip");
617        assert_eq!(http.checksum, "123456");
618        assert_eq!(http.integrity, "beef");
619        assert_eq!(lockfile.raw, content);
620    }
621
622    #[test]
623    fn test_generate_lockfile_content() {
624        let dir = testdir!();
625        let file_path = dir.join(SOLDEER_LOCK);
626        let content = r#"[[dependencies]]
627name = "test"
628version = "1.0.0"
629git = "git@github.com:test/test.git"
630rev = "123456"
631
632[[dependencies]]
633name = "test2"
634version = "1.0.0"
635url = "https://example.com/zip.zip"
636checksum = "123456"
637integrity = "beef"
638"#;
639        fs::write(&file_path, content).unwrap();
640        let lockfile = read_lockfile(&file_path).unwrap();
641        let new_content = generate_lockfile_contents(lockfile.entries);
642        assert_eq!(new_content, content);
643    }
644
645    #[test]
646    fn test_add_to_lockfile() {
647        let dir = testdir!();
648        let file_path = dir.join(SOLDEER_LOCK);
649        let content = r#"[[dependencies]]
650name = "test"
651version = "1.0.0"
652git = "git@github.com:test/test.git"
653rev = "123456"
654"#;
655        fs::write(&file_path, content).unwrap();
656        let entry: LockEntry = HttpLockEntry::builder()
657            .name("test2")
658            .version("1.0.0")
659            .url("https://example.com/zip.zip")
660            .checksum("123456")
661            .integrity("beef")
662            .build()
663            .into();
664        let res = add_to_lockfile(entry.clone(), &file_path);
665        assert!(res.is_ok(), "{res:?}");
666        let lockfile = read_lockfile(&file_path).unwrap();
667        assert_eq!(lockfile.entries.len(), 2);
668        assert_eq!(lockfile.entries[1], entry);
669    }
670
671    #[test]
672    fn test_replace_in_lockfile() {
673        let dir = testdir!();
674        let file_path = dir.join(SOLDEER_LOCK);
675        let content = r#"[[dependencies]]
676name = "test"
677version = "1.0.0"
678git = "git@github.com:test/test.git"
679rev = "123456"
680"#;
681        fs::write(&file_path, content).unwrap();
682        let entry: LockEntry = HttpLockEntry::builder()
683            .name("test")
684            .version("2.0.0")
685            .url("https://example.com/zip.zip")
686            .checksum("123456")
687            .integrity("beef")
688            .build()
689            .into();
690        let res = add_to_lockfile(entry.clone(), &file_path);
691        assert!(res.is_ok(), "{res:?}");
692        let lockfile = read_lockfile(&file_path).unwrap();
693        assert_eq!(lockfile.entries.len(), 1);
694        assert_eq!(lockfile.entries[0], entry);
695    }
696
697    #[test]
698    fn test_remove_lock() {
699        let dir = testdir!();
700        let file_path = dir.join(SOLDEER_LOCK);
701        let content = r#"[[dependencies]]
702name = "test"
703version = "1.0.0"
704git = "git@github.com:test/test.git"
705rev = "123456"
706
707[[dependencies]]
708name = "test2"
709version = "1.0.0"
710url = "https://example.com/zip.zip"
711checksum = "123456"
712integrity = "beef"
713"#;
714        fs::write(&file_path, content).unwrap();
715        let dep = Dependency::from_name_version("test2~2.0.0", None, None).unwrap();
716        let res = remove_lock(&dep, &file_path);
717        assert!(res.is_ok(), "{res:?}");
718        let lockfile = read_lockfile(&file_path).unwrap();
719        assert_eq!(lockfile.entries.len(), 1);
720        assert_eq!(lockfile.entries[0].name(), "test");
721    }
722
723    #[test]
724    fn test_remove_lock_empty() {
725        let dir = testdir!();
726        let file_path = dir.join(SOLDEER_LOCK);
727        let content = r#"[[dependencies]]
728name = "test"
729version = "1.0.0"
730git = "git@github.com:test/test.git"
731rev = "123456"
732"#;
733        fs::write(&file_path, content).unwrap();
734        let dep = Dependency::from_name_version("test~1.0.0", None, None).unwrap();
735        let res = remove_lock(&dep, &file_path);
736        assert!(res.is_ok(), "{res:?}");
737        assert!(!file_path.exists());
738    }
739}