1use 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
23pub trait Integrity {
25 fn install_path(&self, deps: impl AsRef<Path>) -> PathBuf;
27
28 fn integrity(&self) -> Option<&String>;
30}
31
32#[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 pub name: String,
40
41 pub version: String,
43
44 pub git: String,
46
47 pub rev: String,
49}
50
51impl Integrity for GitLockEntry {
52 fn install_path(&self, deps: impl AsRef<Path>) -> PathBuf {
57 format_install_path(&self.name, &self.version, deps)
58 }
59
60 fn integrity(&self) -> Option<&String> {
62 None
63 }
64}
65
66#[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 pub name: String,
74
75 pub version: String,
81
82 pub url: String,
84
85 pub checksum: String,
87
88 pub integrity: String,
90}
91
92impl Integrity for HttpLockEntry {
93 fn install_path(&self, deps: impl AsRef<Path>) -> PathBuf {
98 format_install_path(&self.name, &self.version, deps)
99 }
100
101 fn integrity(&self) -> Option<&String> {
103 Some(&self.integrity)
104 }
105}
106
107#[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 pub name: String,
118
119 pub version: String,
125
126 pub checksum: String,
128
129 pub integrity: String,
131}
132
133impl Integrity for PrivateLockEntry {
134 fn install_path(&self, deps: impl AsRef<Path>) -> PathBuf {
139 format_install_path(&self.name, &self.version, deps)
140 }
141
142 fn integrity(&self) -> Option<&String> {
144 Some(&self.integrity)
145 }
146}
147
148#[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 Http(HttpLockEntry),
173
174 Git(GitLockEntry),
176
177 Private(PrivateLockEntry),
179}
180
181#[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 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 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 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 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 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 pub fn as_http(&self) -> Option<&HttpLockEntry> {
313 if let Self::Http(l) = self { Some(l) } else { None }
314 }
315
316 pub fn as_git(&self) -> Option<&GitLockEntry> {
318 if let Self::Git(l) = self { Some(l) } else { None }
319 }
320
321 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 fn from(value: HttpLockEntry) -> Self {
330 Self::Http(value)
331 }
332}
333
334impl From<GitLockEntry> for LockEntry {
335 fn from(value: GitLockEntry) -> Self {
337 Self::Git(value)
338 }
339}
340
341impl From<PrivateLockEntry> for LockEntry {
342 fn from(value: PrivateLockEntry) -> Self {
344 Self::Private(value)
345 }
346}
347
348#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, Hash)]
352struct LockFileParsed {
353 dependencies: Vec<TomlLockEntry>,
354}
355
356#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
361#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
362pub struct LockFile {
363 pub entries: Vec<LockEntry>,
365
366 pub raw: String,
368}
369
370pub 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
389pub 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
398pub 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
417pub 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 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 fs::write(&path, file_contents)?;
441 debug!(path:? = path.as_ref(); "lockfile modified");
442 Ok(())
443}
444
445pub 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 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}