1use std::{
44 collections::{HashMap, HashSet, VecDeque},
45 path::{Path, PathBuf},
46};
47
48use crate::{
49 fetcher::{FetchedPkg, Fetcher, GitFetcher},
50 lockfile::{join_entry, LockedPkg, Lockfile},
51 manifest::{Dep, Manifest, Package, PatchDrift},
52 resolve_entry,
53 version::{classify_tag_pin, pick_latest_for_pin, pick_latest_overall, TagPin},
54 Config, ManifestSource, PkgError, Project,
55};
56
57#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum Placement {
62 Symlink(PathBuf),
65 Copied(PathBuf),
67}
68
69pub const MANIFEST_REQUESTER: &str = "<manifest>";
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct InstalledPkg {
77 pub name: String,
80 pub requested_by: String,
83 pub sha: String,
85 pub tag: Option<String>,
89 pub entry: PathBuf,
91 pub placement: Placement,
94 pub patched: bool,
97}
98
99impl InstalledPkg {
100 pub fn root(&self) -> &Path {
104 match &self.placement {
105 Placement::Symlink(p) | Placement::Copied(p) => p,
106 }
107 }
108
109 pub fn require_dir(&self) -> PathBuf {
111 join_entry(self.root(), &self.entry)
112 }
113}
114
115fn resolve_root(
124 fetcher: &GitFetcher,
125 project: &Project,
126 name: &str,
127 dep: &Dep,
128 requested_by: &str,
129 prev: Option<&LockedPkg>,
130 warnings: &mut Vec<String>,
131) -> Result<(FetchedPkg, bool), PkgError> {
132 let fetch_err = |e: PkgError| PkgError::Fetch {
133 name: name.to_string(),
134 requested_by: requested_by.to_string(),
135 source: Box::new(e),
136 };
137 let Some(rel_patch) = &dep.patch_dir else {
138 return Ok((fetcher.fetch(dep).map_err(fetch_err)?, false));
139 };
140
141 let patch_root = project.manifest_root().join(rel_patch);
142 let base = prev.and_then(|p| p.patch_base.as_deref());
143 let (sha, tag, upstream) = match fetcher.resolve_sha(dep).map_err(fetch_err)? {
146 Some((sha, tag)) => (sha, tag, None),
147 None => {
148 let f = fetcher.fetch(dep).map_err(fetch_err)?;
149 (f.sha.clone(), f.resolved_tag.clone(), Some(f))
150 }
151 };
152
153 if patch_root.is_dir() && base == Some(sha.as_str()) {
154 return Ok((FetchedPkg::at_root(patch_root, sha, tag)?, true));
155 }
156
157 let reason = if !patch_root.is_dir() {
158 "the directory does not exist".to_string()
159 } else {
160 match base {
161 None => "the lockfile records no patch_base for it".to_string(),
162 Some(b) => format!("it was taken from {b} but the pin resolves to {sha}"),
163 }
164 };
165 if dep.patch_drift == Some(PatchDrift::Error) {
166 return Err(PkgError::PatchDrift {
167 name: name.to_string(),
168 patch_dir: rel_patch.clone(),
169 reason,
170 pinned: sha,
171 });
172 }
173
174 let fetched = match upstream {
175 Some(f) => f,
176 None => fetcher.fetch(dep).map_err(fetch_err)?,
177 };
178 warnings.push(format!(
179 "{name}: patch_dir '{}' not used ({reason}); using upstream {}. \
180 Run `mlua-pkg patch {name} --force` to rebuild the patch from the pinned commit",
181 rel_patch.display(),
182 fetched.sha
183 ));
184 Ok((fetched, false))
185}
186
187#[derive(Debug, Clone, Default, PartialEq, Eq)]
189pub struct InstallReport {
190 pub packages: Vec<InstalledPkg>,
193 pub direct: usize,
195 pub transitive: usize,
198 pub warnings: Vec<String>,
201}
202
203pub fn install(cfg: &Config) -> Result<InstallReport, PkgError> {
246 let project = cfg.project();
247 let manifest = cfg.load_manifest()?;
248
249 let cache_dir = project.pkg_dir().cache();
250 let vendored_dir = project.pkg_dir().vendored();
251 let fetcher = GitFetcher::new(cache_dir);
252 std::fs::create_dir_all(&vendored_dir)?;
253
254 let mut report = InstallReport {
255 direct: manifest.deps.len(),
256 ..InstallReport::default()
257 };
258 let mut locked_pkgs: Vec<LockedPkg> = Vec::with_capacity(manifest.deps.len());
259
260 let prev_lock: HashMap<String, LockedPkg> = match Lockfile::read(project.lock_path()) {
264 Ok(lf) => lf.pkg.into_iter().map(|p| (p.name.clone(), p)).collect(),
265 Err(PkgError::MissingLockfile { .. }) => HashMap::new(),
266 Err(e) => return Err(e),
267 };
268
269 let mut resolved: HashMap<String, (Dep, String)> = HashMap::new();
272 let mut queue: VecDeque<(String, Dep, String)> = VecDeque::new();
273 let mut direct: Vec<(&String, &Dep)> = manifest.deps.iter().collect();
274 direct.sort_by(|a, b| a.0.cmp(b.0));
275 for (name, dep) in direct {
276 queue.push_back((name.clone(), dep.clone(), MANIFEST_REQUESTER.to_string()));
277 }
278
279 while let Some((name, dep, requested_by)) = queue.pop_front() {
280 if let Some((prev, prev_by)) = resolved.get(&name) {
281 if *prev == dep {
282 continue; }
284 return Err(PkgError::DepConflict {
285 name,
286 first: prev_by.clone(),
287 second: requested_by,
288 });
289 }
290 resolved.insert(name.clone(), (dep.clone(), requested_by.clone()));
291 let name = &name;
292 let dep = &dep;
293
294 let (fetched, patched) = resolve_root(
297 &fetcher,
298 project,
299 name,
300 dep,
301 &requested_by,
302 prev_lock.get(name.as_str()),
303 &mut report.warnings,
304 )?;
305
306 if let Some(author) = &fetched.manifest {
308 let mut sub: Vec<(&String, &Dep)> = author.deps.iter().collect();
309 sub.sort_by(|a, b| a.0.cmp(b.0));
310 for (sub_name, sub_dep) in sub {
311 queue.push_back((sub_name.clone(), sub_dep.clone(), name.clone()));
312 }
313 }
314
315 if let Some(author) = &fetched.manifest {
317 if let Some(req_tag) = &dep.tag {
318 let av = &author.package.version;
319 let normalized = req_tag.strip_prefix('v').unwrap_or(req_tag.as_str());
320 if av != req_tag && av != normalized {
321 report.warnings.push(format!(
322 "{name}: requested tag '{req_tag}' vs author manifest version '{av}'"
323 ));
324 }
325 }
326 }
327
328 let author_entry: Option<PathBuf> = fetched
330 .manifest
331 .as_ref()
332 .and_then(|m| m.package.entry.clone());
333 let override_entry: Option<&Path> = dep.entry.as_deref().or(author_entry.as_deref());
334 let entry_abs = resolve_entry(&fetched.cache_path, override_entry)?;
335
336 let root = &fetched.cache_path;
339 let placement = if let Some(rel_target_dir) = &dep.target_dir {
340 let dest = project.manifest_root().join(rel_target_dir);
343 copy_entry_into(root, &dest)?;
344 Placement::Copied(dest)
345 } else {
346 let symlink_path = vendored_dir.join(name);
349 if symlink_path.symlink_metadata().is_ok() {
350 remove_symlink(&symlink_path)?;
351 }
352 let rel_target = relative_path(&vendored_dir, root)?;
353 create_symlink(&rel_target, &symlink_path)?;
354 Placement::Symlink(symlink_path)
355 };
356
357 let entry = entry_rel_to_pkg(root, &entry_abs);
359
360 let locked_tag = fetched.resolved_tag.clone().or_else(|| dep.tag.clone());
363 let patch_base = dep
367 .patch_dir
368 .as_ref()
369 .and_then(|_| prev_lock.get(name.as_str()))
370 .and_then(|p| p.patch_base.clone());
371 locked_pkgs.push(LockedPkg {
372 name: name.clone(),
373 source: format!("git+{}", dep.git),
374 tag: locked_tag.clone(),
375 rev: dep.rev.clone(),
376 branch: dep.branch.clone(),
377 sha: fetched.sha.clone(),
378 entry: entry.clone(),
379 patch_dir: dep.patch_dir.clone(),
380 patch_base,
381 });
382 report.packages.push(InstalledPkg {
383 name: name.clone(),
384 requested_by,
385 sha: fetched.sha,
386 tag: locked_tag,
387 entry,
388 placement,
389 patched,
390 });
391 }
392
393 locked_pkgs.sort_by(|a, b| a.name.cmp(&b.name));
395 report.packages.sort_by(|a, b| a.name.cmp(&b.name));
396 report.transitive = report.packages.len().saturating_sub(report.direct);
397
398 let lockfile = Lockfile {
399 version: 1,
400 pkg: locked_pkgs,
401 };
402 lockfile.write(project.lock_path())?;
403
404 Ok(report)
405}
406
407fn copy_entry_into(src: &Path, dest: &Path) -> std::io::Result<()> {
411 if dest.exists() {
412 std::fs::remove_dir_all(dest)?;
413 }
414 std::fs::create_dir_all(dest)?;
415 copy_dir_contents(src, dest)
416}
417
418fn copy_dir_contents(src: &Path, dest: &Path) -> std::io::Result<()> {
422 for entry in std::fs::read_dir(src)? {
423 let entry = entry?;
424 let from = entry.path();
425 let to = dest.join(entry.file_name());
426 let ft = entry.file_type()?;
427 if ft.is_dir() {
428 std::fs::create_dir_all(&to)?;
429 copy_dir_contents(&from, &to)?;
430 } else if ft.is_symlink() {
431 let resolved = std::fs::canonicalize(&from)?;
434 if resolved.is_dir() {
435 std::fs::create_dir_all(&to)?;
436 copy_dir_contents(&resolved, &to)?;
437 } else {
438 std::fs::copy(&resolved, &to)?;
439 }
440 } else {
441 std::fs::copy(&from, &to)?;
442 }
443 }
444 Ok(())
445}
446
447fn entry_rel_to_pkg(cache_path: &Path, entry_abs: &Path) -> PathBuf {
449 match entry_abs.strip_prefix(cache_path) {
450 Ok(rel) if rel.as_os_str().is_empty() => PathBuf::from("."),
451 Ok(rel) => rel.to_path_buf(),
452 Err(_) => PathBuf::from("."),
453 }
454}
455
456#[derive(Debug, Clone, Default, PartialEq, Eq)]
462pub struct AddSpec {
463 pub name: String,
465 pub git: String,
467 pub tag: Option<String>,
469 pub rev: Option<String>,
471 pub branch: Option<String>,
473 pub entry: Option<PathBuf>,
475 pub target_dir: Option<PathBuf>,
477}
478
479impl AddSpec {
480 pub fn new(name: impl Into<String>, git: impl Into<String>) -> Self {
482 Self {
483 name: name.into(),
484 git: git.into(),
485 ..Default::default()
486 }
487 }
488}
489
490#[derive(Debug, Clone, Copy, PartialEq, Eq)]
492pub enum AddOutcome {
493 Added,
495 Replaced,
497}
498
499#[derive(Debug, Clone, PartialEq, Eq)]
501pub struct AddReport {
502 pub name: String,
504 pub outcome: AddOutcome,
506 pub manifest_created: bool,
509 pub manifest: Manifest,
513}
514
515pub fn add(cfg: &Config, spec: AddSpec) -> Result<AddReport, PkgError> {
534 let project = cfg.project();
535 let ref_count = [
536 spec.tag.is_some(),
537 spec.rev.is_some(),
538 spec.branch.is_some(),
539 ]
540 .into_iter()
541 .filter(|&b| b)
542 .count();
543 if ref_count > 1 {
544 return Err(PkgError::Validation {
545 message: "at most one of tag, rev, branch may be specified".to_string(),
546 });
547 }
548
549 let manifest_path = project.manifest_path();
550 let (mut manifest, manifest_created) = match cfg.manifest_source() {
551 ManifestSource::Value(m) => (m.clone(), false),
552 ManifestSource::File if manifest_path.exists() => {
553 (Manifest::from_path(manifest_path)?, false)
554 }
555 ManifestSource::File => (
556 Manifest {
557 package: Package {
558 name: default_package_name(&project.manifest_root()),
559 version: "0.1.0".to_string(),
560 entry: None,
561 },
562 deps: HashMap::new(),
563 },
564 true,
565 ),
566 };
567
568 let AddSpec {
569 name,
570 git,
571 tag,
572 rev,
573 branch,
574 entry,
575 target_dir,
576 } = spec;
577 let dep = Dep {
578 git,
579 tag,
580 rev,
581 branch,
582 entry,
583 target_dir,
584 patch_dir: None,
585 patch_drift: None,
586 };
587 let existed = manifest.deps.insert(name.clone(), dep).is_some();
588
589 if !cfg.manifest_is_value() {
590 let toml_str =
591 toml::to_string(&manifest).map_err(|e| PkgError::ManifestSerialize { source: e })?;
592 std::fs::write(manifest_path, toml_str)?;
593 }
594
595 Ok(AddReport {
596 name,
597 outcome: if existed {
598 AddOutcome::Replaced
599 } else {
600 AddOutcome::Added
601 },
602 manifest_created,
603 manifest,
604 })
605}
606
607fn default_package_name(manifest_root: &Path) -> String {
610 std::fs::canonicalize(manifest_root)
611 .ok()
612 .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
613 .unwrap_or_else(|| "my-project".to_string())
614}
615
616#[derive(Debug, Clone, Default, PartialEq, Eq)]
620pub struct UpdateOpts {
621 pub name: Option<String>,
623 pub dry_run: bool,
625 pub force: bool,
628}
629
630#[derive(Debug, Clone, PartialEq, Eq)]
632pub enum UpdateOutcome {
633 TagBumped { old: String, new: String },
636 PrefixResolved { pin: String, resolved: String },
640 Refresh,
642 Skipped(String),
645}
646
647#[derive(Debug, Clone, Default, PartialEq, Eq)]
649pub struct UpdateReport {
650 pub entries: Vec<(String, UpdateOutcome)>,
653 pub manifest_modified: bool,
658 pub manifest: Option<Manifest>,
661 pub install: Option<InstallReport>,
664}
665
666pub fn update(cfg: &Config, opts: UpdateOpts) -> Result<UpdateReport, PkgError> {
693 let project = cfg.project();
694 let manifest_path = project.manifest_path();
695 let manifest = cfg.load_manifest()?;
696
697 if let Some(ref n) = opts.name {
698 if !manifest.deps.contains_key(n) {
699 return Err(PkgError::UnknownDep {
700 name: n.clone(),
701 manifest: manifest_path.to_path_buf(),
702 });
703 }
704 }
705
706 let fetcher = GitFetcher::new(project.pkg_dir().cache());
707 let mut doc: Option<toml_edit::DocumentMut> = if cfg.manifest_is_value() {
710 None
711 } else {
712 Some(std::fs::read_to_string(manifest_path)?.parse()?)
713 };
714 let mut new_manifest = manifest.clone();
715
716 let mut report = UpdateReport::default();
717 let mut any_refresh = false;
718 let mut manifest_touched = false;
719
720 for (dep_name, dep) in &manifest.deps {
721 if let Some(ref n) = opts.name {
722 if dep_name != n {
723 continue;
724 }
725 }
726
727 let outcome = update_dep(dep_name, dep, &fetcher, opts.force)?;
728 match &outcome {
729 UpdateOutcome::TagBumped { new, .. } => {
730 if !opts.dry_run {
731 if let Some(doc) = doc.as_mut() {
732 set_dep_tag(doc, dep_name, new)?;
733 }
734 if let Some(d) = new_manifest.deps.get_mut(dep_name) {
735 d.tag = Some(new.clone());
736 }
737 manifest_touched = true;
738 }
739 any_refresh = true;
740 }
741 UpdateOutcome::PrefixResolved { .. } | UpdateOutcome::Refresh => {
742 any_refresh = true;
743 }
744 UpdateOutcome::Skipped(_) => {}
745 }
746 report.entries.push((dep_name.clone(), outcome));
747 }
748
749 if report.entries.is_empty() || opts.dry_run {
750 return Ok(report);
751 }
752
753 if manifest_touched {
754 if let Some(doc) = &doc {
755 std::fs::write(manifest_path, doc.to_string())?;
756 }
757 report.manifest_modified = true;
758 report.manifest = Some(new_manifest.clone());
759 }
760
761 if any_refresh {
762 report.install = Some(install(&cfg.replace_manifest(new_manifest))?);
765 }
766 Ok(report)
767}
768
769#[derive(Debug, Clone, Default, PartialEq, Eq)]
773pub struct PatchOpts {
774 pub name: String,
776 pub force: bool,
778}
779
780#[derive(Debug, Clone, PartialEq, Eq)]
782pub struct PatchReport {
783 pub name: String,
785 pub patch_dir: PathBuf,
787 pub base: String,
789 pub created: bool,
792}
793
794pub fn patch(cfg: &Config, opts: PatchOpts) -> Result<PatchReport, PkgError> {
814 let project = cfg.project();
815 let manifest = cfg.load_manifest()?;
816 let dep = manifest
817 .deps
818 .get(&opts.name)
819 .ok_or_else(|| PkgError::UnknownDep {
820 name: opts.name.clone(),
821 manifest: project.manifest_path().to_path_buf(),
822 })?;
823 let rel_patch = dep.patch_dir.as_ref().ok_or_else(|| PkgError::Validation {
824 message: format!("dep '{}' declares no patch_dir", opts.name),
825 })?;
826 let dest = project.manifest_root().join(rel_patch);
827 let existed = dest.exists();
828 if existed && !opts.force {
829 return Err(PkgError::Validation {
830 message: format!(
831 "patch_dir '{}' already exists; pass --force to rebuild it from the pinned \
832 commit (local edits are discarded)",
833 rel_patch.display()
834 ),
835 });
836 }
837
838 let fetcher = GitFetcher::new(project.pkg_dir().cache());
839 let fetched = fetcher.fetch(dep).map_err(|e| PkgError::Fetch {
840 name: opts.name.clone(),
841 requested_by: MANIFEST_REQUESTER.to_string(),
842 source: Box::new(e),
843 })?;
844 copy_entry_into(&fetched.cache_path, &dest)?;
845
846 let mut lock = match Lockfile::read(project.lock_path()) {
849 Ok(lf) => lf,
850 Err(PkgError::MissingLockfile { .. }) => Lockfile::default(),
851 Err(e) => return Err(e),
852 };
853 match lock.pkg.iter_mut().find(|p| p.name == opts.name) {
854 Some(p) => {
855 p.sha = fetched.sha.clone();
856 p.patch_dir = Some(rel_patch.clone());
857 p.patch_base = Some(fetched.sha.clone());
858 }
859 None => {
860 let author_entry = fetched
861 .manifest
862 .as_ref()
863 .and_then(|m| m.package.entry.clone());
864 let override_entry = dep.entry.as_deref().or(author_entry.as_deref());
865 let entry_abs = resolve_entry(&fetched.cache_path, override_entry)?;
866 lock.pkg.push(LockedPkg {
867 name: opts.name.clone(),
868 source: format!("git+{}", dep.git),
869 tag: fetched.resolved_tag.clone().or_else(|| dep.tag.clone()),
870 rev: dep.rev.clone(),
871 branch: dep.branch.clone(),
872 sha: fetched.sha.clone(),
873 entry: entry_rel_to_pkg(&fetched.cache_path, &entry_abs),
874 patch_dir: Some(rel_patch.clone()),
875 patch_base: Some(fetched.sha.clone()),
876 });
877 }
878 }
879 lock.write(project.lock_path())?;
880
881 Ok(PatchReport {
882 name: opts.name,
883 patch_dir: dest,
884 base: fetched.sha,
885 created: !existed,
886 })
887}
888
889fn set_dep_tag(
891 doc: &mut toml_edit::DocumentMut,
892 name: &str,
893 new_tag: &str,
894) -> Result<(), PkgError> {
895 let deps = doc
896 .get_mut("deps")
897 .and_then(|i| i.as_table_like_mut())
898 .ok_or_else(|| PkgError::ManifestEdit {
899 message: "[deps] table missing".to_string(),
900 })?;
901 let entry = deps.get_mut(name).ok_or_else(|| PkgError::ManifestEdit {
902 message: format!("dep '{name}' missing in [deps]"),
903 })?;
904 if let Some(table) = entry.as_table_like_mut() {
905 table.insert("tag", toml_edit::value(new_tag));
906 Ok(())
907 } else {
908 Err(PkgError::ManifestEdit {
909 message: format!("dep '{name}' is not a table-like entry"),
910 })
911 }
912}
913
914fn update_dep(
919 name: &str,
920 dep: &Dep,
921 fetcher: &GitFetcher,
922 force: bool,
923) -> Result<UpdateOutcome, PkgError> {
924 if dep.rev.is_some() {
925 return Ok(UpdateOutcome::Skipped("rev pin".into()));
926 }
927 if dep.branch.is_some() {
928 return Ok(UpdateOutcome::Refresh);
929 }
930 let Some(current_tag) = &dep.tag else {
931 return Ok(UpdateOutcome::Refresh);
932 };
933
934 let pin = match classify_tag_pin(current_tag) {
935 Some(p) => p,
936 None => {
937 return Ok(UpdateOutcome::Skipped(format!(
938 "tag '{current_tag}' is not SemVer"
939 )))
940 }
941 };
942
943 if matches!(pin, TagPin::Exact) && !force {
945 return Ok(UpdateOutcome::Skipped(
946 "exact tag pin (pass --force to bump)".into(),
947 ));
948 }
949
950 let tags = fetcher.list_tags(&dep.git).map_err(|e| PkgError::Fetch {
951 name: name.to_string(),
952 requested_by: MANIFEST_REQUESTER.to_string(),
953 source: Box::new(e),
954 })?;
955
956 match pin {
957 TagPin::Exact => {
958 let Some(new_tag) = pick_latest_overall(&tags) else {
960 return Ok(UpdateOutcome::Skipped(
961 "no matching SemVer release tag on remote".into(),
962 ));
963 };
964 if &new_tag == current_tag {
965 Ok(UpdateOutcome::Skipped(format!("already at {new_tag}")))
966 } else {
967 Ok(UpdateOutcome::TagBumped {
968 old: current_tag.clone(),
969 new: new_tag,
970 })
971 }
972 }
973 TagPin::Prefix(p) => {
974 let Some(resolved) = pick_latest_for_pin(&tags, &p) else {
977 return Ok(UpdateOutcome::Skipped(
978 "no matching SemVer release tag on remote".into(),
979 ));
980 };
981 Ok(UpdateOutcome::PrefixResolved {
982 pin: current_tag.clone(),
983 resolved,
984 })
985 }
986 }
987}
988
989#[derive(Debug, Clone, Copy, PartialEq, Eq)]
993pub enum CleanReport {
994 CacheRemoved,
996 NoLockfile,
998 StaleRemoved { removed: usize },
1001}
1002
1003pub fn clean(cfg: &Config, all: bool) -> Result<CleanReport, PkgError> {
1013 let project = cfg.project();
1014 let cache_dir = project.pkg_dir().cache();
1015
1016 if all {
1017 if cache_dir.exists() {
1018 std::fs::remove_dir_all(&cache_dir)?;
1019 return Ok(CleanReport::CacheRemoved);
1020 }
1021 return Ok(CleanReport::StaleRemoved { removed: 0 });
1022 }
1023
1024 let lockfile = match Lockfile::read(project.lock_path()) {
1026 Ok(lf) => lf,
1027 Err(PkgError::MissingLockfile { .. }) => return Ok(CleanReport::NoLockfile),
1028 Err(e) => return Err(e),
1029 };
1030
1031 let in_use: HashSet<String> = lockfile.pkg.iter().map(|p| p.sha.clone()).collect();
1032
1033 let git_dir = cache_dir.join("git");
1034 if !git_dir.exists() {
1035 return Ok(CleanReport::StaleRemoved { removed: 0 });
1036 }
1037
1038 let mut removed: usize = 0;
1039 remove_stale_sha_dirs(&git_dir, &in_use, &mut removed)?;
1040 Ok(CleanReport::StaleRemoved { removed })
1041}
1042
1043fn remove_stale_sha_dirs(
1046 dir: &Path,
1047 in_use: &HashSet<String>,
1048 removed: &mut usize,
1049) -> std::io::Result<()> {
1050 for entry in std::fs::read_dir(dir)? {
1051 let entry = entry?;
1052 let path = entry.path();
1053 if !path.is_dir() {
1054 continue;
1055 }
1056 let file_name = entry.file_name();
1057 let name = file_name.to_string_lossy();
1058 if name.len() == 40 && name.chars().all(|c| c.is_ascii_hexdigit()) {
1059 if !in_use.contains(name.as_ref()) {
1060 std::fs::remove_dir_all(&path)?;
1061 *removed += 1;
1062 }
1063 } else {
1064 remove_stale_sha_dirs(&path, in_use, removed)?;
1066 }
1067 }
1068 Ok(())
1069}
1070
1071#[cfg(unix)]
1075fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
1076 std::os::unix::fs::symlink(target, link)
1077}
1078
1079#[cfg(windows)]
1080fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
1081 std::os::windows::fs::symlink_dir(target, link)
1082}
1083
1084#[cfg(not(any(unix, windows)))]
1085fn create_symlink(_target: &Path, _link: &Path) -> std::io::Result<()> {
1086 Err(std::io::Error::new(
1087 std::io::ErrorKind::Unsupported,
1088 "symlinks not supported on this platform",
1089 ))
1090}
1091
1092#[cfg(unix)]
1094fn remove_symlink(path: &Path) -> std::io::Result<()> {
1095 std::fs::remove_file(path)
1096}
1097
1098#[cfg(windows)]
1099fn remove_symlink(path: &Path) -> std::io::Result<()> {
1100 if path.is_dir() {
1102 std::fs::remove_dir(path)
1103 } else {
1104 std::fs::remove_file(path)
1105 }
1106}
1107
1108#[cfg(not(any(unix, windows)))]
1109fn remove_symlink(path: &Path) -> std::io::Result<()> {
1110 std::fs::remove_file(path)
1111}
1112
1113fn relative_path(from_dir: &Path, to: &Path) -> std::io::Result<PathBuf> {
1119 let from_abs = std::fs::canonicalize(from_dir)?;
1120 let to_abs = std::fs::canonicalize(to)?;
1121
1122 let from_parts: Vec<_> = from_abs.components().collect();
1123 let to_parts: Vec<_> = to_abs.components().collect();
1124
1125 let common = from_parts
1126 .iter()
1127 .zip(to_parts.iter())
1128 .take_while(|(a, b)| a == b)
1129 .count();
1130
1131 let mut rel = PathBuf::new();
1132 for _ in &from_parts[common..] {
1133 rel.push("..");
1134 }
1135 for c in &to_parts[common..] {
1136 rel.push(c);
1137 }
1138 Ok(rel)
1139}
1140
1141#[cfg(test)]
1144mod tests {
1145 use super::*;
1146 use crate::{PkgDir, Project};
1147 use tempfile::TempDir;
1148
1149 fn init_repo_with_commit(dir: &Path) -> String {
1154 use git2::{Repository, Signature};
1155
1156 let repo = Repository::init(dir).unwrap();
1157 {
1158 let mut cfg = repo.config().unwrap();
1159 cfg.set_str("user.name", "Test").unwrap();
1160 cfg.set_str("user.email", "test@example.com").unwrap();
1161 }
1162
1163 std::fs::write(dir.join("main.lua"), "return {}\n").unwrap();
1164
1165 let mut index = repo.index().unwrap();
1166 index.add_path(Path::new("main.lua")).unwrap();
1167 index.write().unwrap();
1168 let tree_id = index.write_tree().unwrap();
1169 let tree = repo.find_tree(tree_id).unwrap();
1170 let sig = Signature::now("Test", "test@example.com").unwrap();
1171 let oid = repo
1172 .commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
1173 .unwrap();
1174 oid.to_string()
1175 }
1176
1177 fn add_tag(dir: &Path, tag: &str) {
1179 use git2::{Repository, Signature};
1180 let repo = Repository::open(dir).unwrap();
1181 let head = repo.head().unwrap().peel_to_commit().unwrap();
1182 let sig = Signature::now("Test", "test@example.com").unwrap();
1183 repo.tag(tag, head.as_object(), &sig, tag, false).unwrap();
1184 }
1185
1186 fn write_file(path: &Path, content: &str) {
1188 if let Some(parent) = path.parent() {
1189 std::fs::create_dir_all(parent).unwrap();
1190 }
1191 std::fs::write(path, content).unwrap();
1192 }
1193
1194 fn project_in(dir: &Path) -> Project {
1196 Project::in_dir(dir, PkgDir::default_in(dir))
1197 }
1198
1199 fn cfg(p: &Project) -> Config {
1201 Config::new(p.clone())
1202 }
1203
1204 fn manifest_with_dep(url: &str, dep_body: &str) -> String {
1205 format!(
1206 "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n\
1207 [deps]\nmylib = {{ git = \"{url}\", {dep_body} }}\n"
1208 )
1209 }
1210
1211 #[test]
1214 fn install_creates_lockfile_and_symlink() {
1215 let remote = TempDir::new().unwrap();
1216 let sha = init_repo_with_commit(remote.path());
1217
1218 let dir = TempDir::new().unwrap();
1219 let project = project_in(dir.path());
1220 let url = format!("file://{}", remote.path().display());
1221 write_file(
1222 project.manifest_path(),
1223 &manifest_with_dep(&url, &format!("rev = \"{sha}\"")),
1224 );
1225
1226 let report = install(&cfg(&project)).unwrap();
1227
1228 assert_eq!(report.packages.len(), 1);
1229 assert_eq!(report.packages[0].name, "mylib");
1230 assert_eq!(report.packages[0].sha, sha);
1231 assert!(report.warnings.is_empty());
1232
1233 assert!(project.lock_path().exists(), "lockfile must be written");
1235 let lf = Lockfile::read(project.lock_path()).unwrap();
1236 assert_eq!(lf.pkg.len(), 1, "one locked package");
1237 assert_eq!(lf.pkg[0].name, "mylib");
1238 assert_eq!(lf.pkg[0].sha, sha);
1239 assert_eq!(lf.pkg[0].source, format!("git+{url}"));
1240
1241 let symlink = project.pkg_dir().vendored().join("mylib");
1243 assert_eq!(
1244 report.packages[0].placement,
1245 Placement::Symlink(symlink.clone())
1246 );
1247 assert!(
1248 symlink.symlink_metadata().is_ok(),
1249 "symlink <vendored>/mylib must exist"
1250 );
1251 let target = std::fs::read_link(&symlink).unwrap();
1253 assert!(
1254 target.is_relative(),
1255 "symlink target must be a relative path, got: {}",
1256 target.display()
1257 );
1258 }
1259
1260 #[test]
1261 fn install_with_target_dir_physically_copies() {
1262 let remote = TempDir::new().unwrap();
1263 let sha = init_repo_with_commit(remote.path());
1264
1265 let dir = TempDir::new().unwrap();
1266 let project = project_in(dir.path());
1267 let url = format!("file://{}", remote.path().display());
1268 write_file(
1269 project.manifest_path(),
1270 &manifest_with_dep(
1271 &url,
1272 &format!("rev = \"{sha}\", target_dir = \"lua/mylib\""),
1273 ),
1274 );
1275
1276 let report = install(&cfg(&project)).unwrap();
1277
1278 let vendored_file = dir.path().join("lua/mylib/main.lua");
1280 assert!(
1281 vendored_file.exists(),
1282 "vendored file must exist at target_dir"
1283 );
1284 let meta = std::fs::symlink_metadata(&vendored_file).unwrap();
1285 assert!(
1286 !meta.file_type().is_symlink(),
1287 "vendored output must be a regular file, not a symlink"
1288 );
1289 assert_eq!(
1290 report.packages[0].placement,
1291 Placement::Copied(dir.path().join("lua/mylib"))
1292 );
1293
1294 assert!(
1296 project
1297 .pkg_dir()
1298 .vendored()
1299 .join("mylib")
1300 .symlink_metadata()
1301 .is_err(),
1302 "<vendored>/<name> must not be created when target_dir is set"
1303 );
1304
1305 let lf = Lockfile::read(project.lock_path()).unwrap();
1307 assert_eq!(lf.pkg.len(), 1);
1308 assert_eq!(lf.pkg[0].sha, sha);
1309 }
1310
1311 #[test]
1312 fn install_with_target_dir_is_idempotent() {
1313 let remote = TempDir::new().unwrap();
1314 let sha = init_repo_with_commit(remote.path());
1315
1316 let dir = TempDir::new().unwrap();
1317 let project = project_in(dir.path());
1318 let url = format!("file://{}", remote.path().display());
1319 write_file(
1320 project.manifest_path(),
1321 &manifest_with_dep(
1322 &url,
1323 &format!("rev = \"{sha}\", target_dir = \"lua/mylib\""),
1324 ),
1325 );
1326
1327 install(&cfg(&project)).unwrap();
1328 install(&cfg(&project)).unwrap();
1329
1330 assert!(dir.path().join("lua/mylib/main.lua").exists());
1331 }
1332
1333 fn commit_files(dir: &Path, files: &[(&str, &str)], msg: &str) -> String {
1337 use git2::{Repository, Signature};
1338 let repo = Repository::open(dir).unwrap();
1339 let mut index = repo.index().unwrap();
1340 for (rel, content) in files {
1341 write_file(&dir.join(rel), content);
1342 index.add_path(Path::new(rel)).unwrap();
1343 }
1344 index.write().unwrap();
1345 let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
1346 let sig = Signature::now("Test", "test@example.com").unwrap();
1347 let parent = repo.head().unwrap().peel_to_commit().unwrap();
1348 repo.commit(Some("HEAD"), &sig, &sig, msg, &tree, &[&parent])
1349 .unwrap()
1350 .to_string()
1351 }
1352
1353 fn init_repo_with_src_and_types(dir: &Path) -> String {
1355 init_repo_with_commit(dir);
1356 std::fs::remove_file(dir.join("main.lua")).unwrap();
1357 commit_files(
1358 dir,
1359 &[
1360 ("src/main.lua", "return { v = 1 }\n"),
1361 ("types/main.d.lua", "---@meta\n"),
1362 ],
1363 "layout",
1364 )
1365 }
1366
1367 #[test]
1368 fn install_links_package_root_and_records_entry() {
1369 let remote = TempDir::new().unwrap();
1370 let sha = init_repo_with_src_and_types(remote.path());
1371
1372 let dir = TempDir::new().unwrap();
1373 let project = project_in(dir.path());
1374 let url = format!("file://{}", remote.path().display());
1375 write_file(
1376 project.manifest_path(),
1377 &manifest_with_dep(&url, &format!("rev = \"{sha}\"")),
1378 );
1379
1380 let report = install(&cfg(&project)).unwrap();
1381 let pkg = &report.packages[0];
1382 assert_eq!(pkg.entry, PathBuf::from("src"));
1383 assert!(!pkg.patched);
1384 assert!(pkg.root().join("types/main.d.lua").exists());
1386 assert!(pkg.require_dir().join("main.lua").exists());
1387 assert_eq!(pkg.root(), project.pkg_dir().vendored_root("mylib"));
1388
1389 let lf = Lockfile::read(project.lock_path()).unwrap();
1390 assert_eq!(lf.pkg[0].entry, PathBuf::from("src"));
1391 assert_eq!(
1392 lf.pkg[0].require_dir(pkg.root()),
1393 project.pkg_dir().vendored_root("mylib").join("src")
1394 );
1395 assert_eq!(lf.pkg[0].patch_dir, None);
1396 assert_eq!(lf.pkg[0].patch_base, None);
1397 }
1398
1399 #[test]
1400 fn install_with_target_dir_copies_package_root() {
1401 let remote = TempDir::new().unwrap();
1402 let sha = init_repo_with_src_and_types(remote.path());
1403
1404 let dir = TempDir::new().unwrap();
1405 let project = project_in(dir.path());
1406 let url = format!("file://{}", remote.path().display());
1407 write_file(
1408 project.manifest_path(),
1409 &manifest_with_dep(
1410 &url,
1411 &format!("rev = \"{sha}\", target_dir = \"lua/mylib\""),
1412 ),
1413 );
1414
1415 let report = install(&cfg(&project)).unwrap();
1416 let pkg = &report.packages[0];
1417 assert_eq!(
1418 pkg.placement,
1419 Placement::Copied(dir.path().join("lua/mylib"))
1420 );
1421 assert!(dir.path().join("lua/mylib/src/main.lua").exists());
1422 assert!(dir.path().join("lua/mylib/types/main.d.lua").exists());
1423 assert_eq!(pkg.require_dir(), dir.path().join("lua/mylib/src"));
1424 }
1425
1426 fn manifest_with_patched_dep(url: &str, sha: &str) -> String {
1429 manifest_with_dep(
1430 url,
1431 &format!("rev = \"{sha}\", patch_dir = \"patches/mylib\""),
1432 )
1433 }
1434
1435 #[test]
1436 fn patch_creates_dir_and_install_resolves_from_it() {
1437 let remote = TempDir::new().unwrap();
1438 let sha = init_repo_with_src_and_types(remote.path());
1439
1440 let dir = TempDir::new().unwrap();
1441 let project = project_in(dir.path());
1442 let url = format!("file://{}", remote.path().display());
1443 write_file(
1444 project.manifest_path(),
1445 &manifest_with_patched_dep(&url, &sha),
1446 );
1447
1448 let report = install(&cfg(&project)).unwrap();
1450 assert!(!report.packages[0].patched);
1451 assert_eq!(report.warnings.len(), 1, "{:?}", report.warnings);
1452 assert!(report.warnings[0].contains("patches/mylib"));
1453 assert!(report.warnings[0].contains("does not exist"));
1454
1455 let pr = patch(
1456 &cfg(&project),
1457 PatchOpts {
1458 name: "mylib".into(),
1459 force: false,
1460 },
1461 )
1462 .unwrap();
1463 assert!(pr.created);
1464 assert_eq!(pr.base, sha);
1465 let patch_root = dir.path().join("patches/mylib");
1466 assert_eq!(pr.patch_dir, patch_root);
1467 assert!(patch_root.join("src/main.lua").exists());
1468 assert!(patch_root.join("types/main.d.lua").exists());
1469 let lf = Lockfile::read(project.lock_path()).unwrap();
1470 assert_eq!(lf.pkg[0].patch_base.as_deref(), Some(sha.as_str()));
1471 assert_eq!(lf.pkg[0].patch_dir, Some(PathBuf::from("patches/mylib")));
1472
1473 write_file(
1475 &patch_root.join("src/main.lua"),
1476 "return { v = 'patched' }\n",
1477 );
1478 let report = install(&cfg(&project)).unwrap();
1479 let pkg = &report.packages[0];
1480 assert!(pkg.patched, "{:?}", report.warnings);
1481 assert!(report.warnings.is_empty(), "{:?}", report.warnings);
1482 assert_eq!(pkg.sha, sha);
1483 assert_eq!(
1484 std::fs::canonicalize(pkg.root()).unwrap(),
1485 std::fs::canonicalize(&patch_root).unwrap()
1486 );
1487 assert_eq!(
1488 std::fs::read_to_string(pkg.require_dir().join("main.lua")).unwrap(),
1489 "return { v = 'patched' }\n"
1490 );
1491 let lf = Lockfile::read(project.lock_path()).unwrap();
1492 assert_eq!(lf.pkg[0].patch_base.as_deref(), Some(sha.as_str()));
1493
1494 let err = patch(
1496 &cfg(&project),
1497 PatchOpts {
1498 name: "mylib".into(),
1499 force: false,
1500 },
1501 )
1502 .unwrap_err();
1503 assert!(matches!(err, PkgError::Validation { .. }), "{err}");
1504 assert_eq!(
1505 std::fs::read_to_string(patch_root.join("src/main.lua")).unwrap(),
1506 "return { v = 'patched' }\n"
1507 );
1508 }
1509
1510 #[test]
1511 fn patch_drift_falls_back_to_upstream_and_keeps_dir() {
1512 let remote = TempDir::new().unwrap();
1513 let sha1 = init_repo_with_src_and_types(remote.path());
1514
1515 let dir = TempDir::new().unwrap();
1516 let project = project_in(dir.path());
1517 let url = format!("file://{}", remote.path().display());
1518 write_file(
1519 project.manifest_path(),
1520 &manifest_with_patched_dep(&url, &sha1),
1521 );
1522 patch(
1523 &cfg(&project),
1524 PatchOpts {
1525 name: "mylib".into(),
1526 force: false,
1527 },
1528 )
1529 .unwrap();
1530 let patch_root = dir.path().join("patches/mylib");
1531 write_file(
1532 &patch_root.join("src/main.lua"),
1533 "return { v = 'patched' }\n",
1534 );
1535
1536 let sha2 = commit_files(
1538 remote.path(),
1539 &[("src/main.lua", "return { v = 2 }\n")],
1540 "second",
1541 );
1542 write_file(
1543 project.manifest_path(),
1544 &manifest_with_patched_dep(&url, &sha2),
1545 );
1546
1547 let report = install(&cfg(&project)).unwrap();
1548 let pkg = &report.packages[0];
1549 assert!(!pkg.patched);
1550 assert_eq!(pkg.sha, sha2);
1551 assert_eq!(report.warnings.len(), 1, "{:?}", report.warnings);
1552 assert!(report.warnings[0].contains(&sha1), "{}", report.warnings[0]);
1553 assert!(report.warnings[0].contains(&sha2), "{}", report.warnings[0]);
1554 assert_eq!(
1556 std::fs::read_to_string(patch_root.join("src/main.lua")).unwrap(),
1557 "return { v = 'patched' }\n"
1558 );
1559 let lf = Lockfile::read(project.lock_path()).unwrap();
1560 assert_eq!(lf.pkg[0].sha, sha2);
1561 assert_eq!(lf.pkg[0].patch_base.as_deref(), Some(sha1.as_str()));
1562 assert_eq!(
1563 std::fs::read_to_string(pkg.require_dir().join("main.lua")).unwrap(),
1564 "return { v = 2 }\n"
1565 );
1566
1567 let pr = patch(
1569 &cfg(&project),
1570 PatchOpts {
1571 name: "mylib".into(),
1572 force: true,
1573 },
1574 )
1575 .unwrap();
1576 assert!(!pr.created);
1577 assert_eq!(pr.base, sha2);
1578 assert_eq!(
1579 std::fs::read_to_string(patch_root.join("src/main.lua")).unwrap(),
1580 "return { v = 2 }\n"
1581 );
1582 let report = install(&cfg(&project)).unwrap();
1583 assert!(report.packages[0].patched);
1584 assert!(report.warnings.is_empty(), "{:?}", report.warnings);
1585 }
1586
1587 #[test]
1588 fn patch_drift_error_policy_stops_install() {
1589 let remote = TempDir::new().unwrap();
1590 let sha1 = init_repo_with_src_and_types(remote.path());
1591
1592 let dir = TempDir::new().unwrap();
1593 let project = project_in(dir.path());
1594 let url = format!("file://{}", remote.path().display());
1595 let strict = |sha: &str| {
1596 manifest_with_dep(
1597 &url,
1598 &format!("rev = \"{sha}\", patch_dir = \"patches/mylib\", patch_drift = \"error\""),
1599 )
1600 };
1601 write_file(project.manifest_path(), &strict(&sha1));
1602
1603 let err = install(&cfg(&project)).unwrap_err();
1605 assert!(
1606 matches!(&err, PkgError::PatchDrift { name, reason, .. }
1607 if name == "mylib" && reason.contains("does not exist")),
1608 "{err}"
1609 );
1610 assert!(
1611 !project.lock_path().exists(),
1612 "lockfile must not be written"
1613 );
1614
1615 patch(
1616 &cfg(&project),
1617 PatchOpts {
1618 name: "mylib".into(),
1619 force: false,
1620 },
1621 )
1622 .unwrap();
1623 let report = install(&cfg(&project)).unwrap();
1624 assert!(report.packages[0].patched);
1625 assert!(report.warnings.is_empty());
1626
1627 let patch_root = dir.path().join("patches/mylib");
1630 write_file(
1631 &patch_root.join("src/main.lua"),
1632 "return { v = 'patched' }\n",
1633 );
1634 let sha2 = commit_files(
1635 remote.path(),
1636 &[("src/main.lua", "return { v = 2 }\n")],
1637 "second",
1638 );
1639 write_file(project.manifest_path(), &strict(&sha2));
1640 let err = install(&cfg(&project)).unwrap_err();
1641 match &err {
1642 PkgError::PatchDrift { reason, pinned, .. } => {
1643 assert!(reason.contains(&sha1), "{reason}");
1644 assert_eq!(pinned, &sha2);
1645 }
1646 other => panic!("expected PatchDrift, got {other}"),
1647 }
1648 assert!(err.to_string().contains("mlua-pkg patch mylib --force"));
1649 assert_eq!(
1650 std::fs::read_to_string(patch_root.join("src/main.lua")).unwrap(),
1651 "return { v = 'patched' }\n"
1652 );
1653 let lf = Lockfile::read(project.lock_path()).unwrap();
1654 assert_eq!(lf.pkg[0].sha, sha1);
1655 assert_eq!(lf.pkg[0].patch_base.as_deref(), Some(sha1.as_str()));
1656 }
1657
1658 #[test]
1659 fn patch_requires_patch_dir_in_manifest() {
1660 let remote = TempDir::new().unwrap();
1661 let sha = init_repo_with_commit(remote.path());
1662 let dir = TempDir::new().unwrap();
1663 let project = project_in(dir.path());
1664 let url = format!("file://{}", remote.path().display());
1665 write_file(
1666 project.manifest_path(),
1667 &manifest_with_dep(&url, &format!("rev = \"{sha}\"")),
1668 );
1669 let err = patch(
1670 &cfg(&project),
1671 PatchOpts {
1672 name: "mylib".into(),
1673 force: false,
1674 },
1675 )
1676 .unwrap_err();
1677 assert!(matches!(err, PkgError::Validation { .. }), "{err}");
1678 let err = patch(
1679 &cfg(&project),
1680 PatchOpts {
1681 name: "nope".into(),
1682 force: false,
1683 },
1684 )
1685 .unwrap_err();
1686 assert!(matches!(err, PkgError::UnknownDep { .. }), "{err}");
1687 }
1688
1689 #[test]
1690 fn install_missing_manifest_returns_error() {
1691 let dir = TempDir::new().unwrap();
1692 let result = install(&cfg(&project_in(dir.path())));
1693 assert!(result.is_err(), "must fail when mlua-pkg.toml is absent");
1694 }
1695
1696 #[test]
1697 fn install_is_idempotent() {
1698 let remote = TempDir::new().unwrap();
1700 let sha = init_repo_with_commit(remote.path());
1701
1702 let dir = TempDir::new().unwrap();
1703 let project = project_in(dir.path());
1704 let url = format!("file://{}", remote.path().display());
1705 write_file(
1706 project.manifest_path(),
1707 &manifest_with_dep(&url, &format!("rev = \"{sha}\"")),
1708 );
1709
1710 install(&cfg(&project)).unwrap();
1711 install(&cfg(&project)).unwrap();
1712
1713 let lf = Lockfile::read(project.lock_path()).unwrap();
1714 assert_eq!(lf.pkg.len(), 1);
1715 }
1716
1717 #[test]
1718 fn install_with_prefix_pin_resolves_to_concrete_tag() {
1719 let remote = TempDir::new().unwrap();
1720 init_repo_with_commit(remote.path());
1721 add_tag(remote.path(), "v1.0.0");
1722 add_tag(remote.path(), "v1.0.5");
1723 add_tag(remote.path(), "v2.0.0");
1724
1725 let dir = TempDir::new().unwrap();
1726 let project = project_in(dir.path());
1727 let url = format!("file://{}", remote.path().display());
1728 write_file(
1729 project.manifest_path(),
1730 &manifest_with_dep(&url, "tag = \"v1.0\""),
1731 );
1732
1733 let report = install(&cfg(&project)).unwrap();
1734 assert_eq!(report.packages[0].tag.as_deref(), Some("v1.0.5"));
1735
1736 let lf = Lockfile::read(project.lock_path()).unwrap();
1737 assert_eq!(
1738 lf.pkg[0].tag.as_deref(),
1739 Some("v1.0.5"),
1740 "install must resolve prefix v1.0 to concrete v1.0.5 (excluding v2.0.0):\n{lf:?}"
1741 );
1742 }
1743
1744 #[test]
1745 fn install_fetch_failure_names_the_dep() {
1746 let dir = TempDir::new().unwrap();
1747 let project = project_in(dir.path());
1748 write_file(
1749 project.manifest_path(),
1750 &manifest_with_dep("file:///nonexistent/repo", "branch = \"main\""),
1751 );
1752
1753 let err = install(&cfg(&project)).unwrap_err();
1754 assert!(
1755 matches!(&err, PkgError::Fetch { name, .. } if name == "mylib"),
1756 "expected Fetch {{ name: mylib }}, got: {err:?}"
1757 );
1758 }
1759
1760 #[test]
1761 fn install_uses_pkg_dir_not_cwd_convention() {
1762 let remote = TempDir::new().unwrap();
1764 let sha = init_repo_with_commit(remote.path());
1765
1766 let dir = TempDir::new().unwrap();
1767 let custom = dir.path().join("elsewhere/pkgs");
1768 let project = Project::in_dir(dir.path(), PkgDir::new(&custom));
1769 let url = format!("file://{}", remote.path().display());
1770 write_file(
1771 project.manifest_path(),
1772 &manifest_with_dep(&url, &format!("rev = \"{sha}\"")),
1773 );
1774
1775 install(&cfg(&project)).unwrap();
1776
1777 assert!(custom.join("cache").is_dir());
1778 assert!(custom.join("vendored/mylib").symlink_metadata().is_ok());
1779 assert!(!dir.path().join(".mlua-pkgs").exists());
1780 }
1781
1782 #[test]
1785 fn install_value_manifest_never_touches_manifest_path() {
1786 let remote = TempDir::new().unwrap();
1787 let sha = init_repo_with_commit(remote.path());
1788
1789 let dir = TempDir::new().unwrap();
1790 let project = project_in(dir.path());
1791 let url = format!("file://{}", remote.path().display());
1792 let manifest =
1793 Manifest::from_toml_str(&manifest_with_dep(&url, &format!("rev = \"{sha}\""))).unwrap();
1794 let vcfg = Config::with_manifest(project.clone(), manifest);
1795
1796 let report = install(&vcfg).unwrap();
1797
1798 assert_eq!(report.packages[0].sha, sha);
1799 assert!(
1800 !project.manifest_path().exists(),
1801 "value mode must not write the manifest"
1802 );
1803 assert!(project.lock_path().exists(), "lockfile is still an output");
1804 assert!(project
1805 .pkg_dir()
1806 .vendored()
1807 .join("mylib")
1808 .symlink_metadata()
1809 .is_ok());
1810 }
1811
1812 #[test]
1813 fn add_value_mode_returns_manifest_without_writing() {
1814 let dir = TempDir::new().unwrap();
1815 let project = project_in(dir.path());
1816 let base =
1817 Manifest::from_toml_str("[package]\nname = \"v\"\nversion = \"0.1.0\"\n").unwrap();
1818 let vcfg = Config::with_manifest(project.clone(), base);
1819
1820 let report = add(&vcfg, AddSpec::new("lib", "https://github.com/x/lib")).unwrap();
1821
1822 assert_eq!(report.outcome, AddOutcome::Added);
1823 assert!(!report.manifest_created);
1824 assert_eq!(report.manifest.deps["lib"].git, "https://github.com/x/lib");
1825 assert!(
1826 !project.manifest_path().exists(),
1827 "value mode must not write the manifest"
1828 );
1829 assert!(vcfg.load_manifest().unwrap().deps.is_empty());
1831 }
1832
1833 #[test]
1834 fn update_value_mode_force_returns_new_manifest_and_installs() {
1835 let remote = TempDir::new().unwrap();
1836 init_repo_with_commit(remote.path());
1837 add_tag(remote.path(), "v1.0.0");
1838 add_tag(remote.path(), "v2.0.0");
1839
1840 let dir = TempDir::new().unwrap();
1841 let project = project_in(dir.path());
1842 let url = format!("file://{}", remote.path().display());
1843 let manifest =
1844 Manifest::from_toml_str(&manifest_with_dep(&url, "tag = \"v1.0.0\"")).unwrap();
1845 let vcfg = Config::with_manifest(project.clone(), manifest);
1846
1847 let report = update(
1848 &vcfg,
1849 UpdateOpts {
1850 force: true,
1851 ..Default::default()
1852 },
1853 )
1854 .unwrap();
1855
1856 assert!(report.manifest_modified);
1857 let new_manifest = report.manifest.expect("new manifest returned");
1858 assert_eq!(new_manifest.deps["mylib"].tag.as_deref(), Some("v2.0.0"));
1859 assert!(
1860 !project.manifest_path().exists(),
1861 "value mode must not write the manifest"
1862 );
1863 assert!(report.install.is_some());
1865 let lf = Lockfile::read(project.lock_path()).unwrap();
1866 assert_eq!(lf.pkg[0].tag.as_deref(), Some("v2.0.0"));
1867 }
1868
1869 #[test]
1870 fn update_file_mode_reports_new_manifest_too() {
1871 let remote = TempDir::new().unwrap();
1872 init_repo_with_commit(remote.path());
1873 add_tag(remote.path(), "v1.0.0");
1874 add_tag(remote.path(), "v2.0.0");
1875
1876 let dir = TempDir::new().unwrap();
1877 let project = project_in(dir.path());
1878 let url = format!("file://{}", remote.path().display());
1879 write_file(
1880 project.manifest_path(),
1881 &manifest_with_dep(&url, "tag = \"v1.0.0\""),
1882 );
1883
1884 let report = update(
1885 &cfg(&project),
1886 UpdateOpts {
1887 force: true,
1888 ..Default::default()
1889 },
1890 )
1891 .unwrap();
1892
1893 let new_manifest = report.manifest.expect("new manifest returned");
1894 assert_eq!(new_manifest.deps["mylib"].tag.as_deref(), Some("v2.0.0"));
1895 assert_eq!(
1897 Manifest::from_path(project.manifest_path()).unwrap(),
1898 new_manifest
1899 );
1900 }
1901
1902 fn init_repo_with_files(dir: &Path, files: &[(&str, &str)]) -> String {
1907 use git2::{Repository, Signature};
1908
1909 let repo = Repository::init(dir).unwrap();
1910 {
1911 let mut cfg = repo.config().unwrap();
1912 cfg.set_str("user.name", "Test").unwrap();
1913 cfg.set_str("user.email", "test@example.com").unwrap();
1914 }
1915 let mut index = repo.index().unwrap();
1916 for (rel, content) in files {
1917 write_file(&dir.join(rel), content);
1918 index.add_path(Path::new(rel)).unwrap();
1919 }
1920 index.write().unwrap();
1921 let tree_id = index.write_tree().unwrap();
1922 let tree = repo.find_tree(tree_id).unwrap();
1923 let sig = Signature::now("Test", "test@example.com").unwrap();
1924 repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
1925 .unwrap()
1926 .to_string()
1927 }
1928
1929 fn leaf_and_mid(leaf: &TempDir, mid: &TempDir) -> (String, String, String, String) {
1933 let leaf_sha =
1934 init_repo_with_files(leaf.path(), &[("main.lua", "return { leaf = true }\n")]);
1935 let leaf_url = format!("file://{}", leaf.path().display());
1936 let mid_manifest = format!(
1937 "[package]\nname = \"mid\"\nversion = \"0.1.0\"\n\n\
1938 [deps]\nleaf = {{ git = \"{leaf_url}\", rev = \"{leaf_sha}\" }}\n"
1939 );
1940 let mid_sha = init_repo_with_files(
1941 mid.path(),
1942 &[
1943 ("main.lua", "return require('leaf')\n"),
1944 ("mlua-pkg.toml", &mid_manifest),
1945 ],
1946 );
1947 let mid_url = format!("file://{}", mid.path().display());
1948 (leaf_url, leaf_sha, mid_url, mid_sha)
1949 }
1950
1951 #[test]
1952 fn install_resolves_transitive_deps_from_author_manifest() {
1953 let (leaf, mid) = (TempDir::new().unwrap(), TempDir::new().unwrap());
1954 let (_leaf_url, leaf_sha, mid_url, mid_sha) = leaf_and_mid(&leaf, &mid);
1955
1956 let dir = TempDir::new().unwrap();
1958 let project = project_in(dir.path());
1959 write_file(
1960 project.manifest_path(),
1961 &format!(
1962 "[package]\nname = \"root\"\nversion = \"0.1.0\"\n\n\
1963 [deps]\nmid = {{ git = \"{mid_url}\", rev = \"{mid_sha}\" }}\n"
1964 ),
1965 );
1966
1967 let report = install(&cfg(&project)).unwrap();
1968
1969 assert_eq!(report.direct, 1);
1970 assert_eq!(report.transitive, 1);
1971 let names: Vec<&str> = report.packages.iter().map(|p| p.name.as_str()).collect();
1972 assert_eq!(names, vec!["leaf", "mid"], "report sorted by name");
1973 assert_eq!(report.packages[0].requested_by, "mid");
1974 assert_eq!(report.packages[1].requested_by, MANIFEST_REQUESTER);
1975
1976 let lf = Lockfile::read(project.lock_path()).unwrap();
1977 let names: Vec<&str> = lf.pkg.iter().map(|p| p.name.as_str()).collect();
1978 assert_eq!(
1979 names,
1980 vec!["leaf", "mid"],
1981 "both packages locked, sorted by name"
1982 );
1983 assert_eq!(lf.pkg[0].sha, leaf_sha);
1984 assert_eq!(lf.pkg[1].sha, mid_sha);
1985 let vendored = project.pkg_dir().vendored();
1986 assert!(
1987 vendored.join("leaf").symlink_metadata().is_ok(),
1988 "leaf vendored"
1989 );
1990 assert!(
1991 vendored.join("mid").symlink_metadata().is_ok(),
1992 "mid vendored"
1993 );
1994
1995 install(&cfg(&project)).unwrap();
1997 assert_eq!(Lockfile::read(project.lock_path()).unwrap().pkg.len(), 2);
1998 }
1999
2000 #[test]
2001 fn install_same_dep_reached_twice_with_identical_spec_is_fine() {
2002 let (leaf, mid) = (TempDir::new().unwrap(), TempDir::new().unwrap());
2003 let (leaf_url, leaf_sha, mid_url, mid_sha) = leaf_and_mid(&leaf, &mid);
2004
2005 let dir = TempDir::new().unwrap();
2006 let project = project_in(dir.path());
2007 write_file(
2009 project.manifest_path(),
2010 &format!(
2011 "[package]\nname = \"root\"\nversion = \"0.1.0\"\n\n\
2012 [deps]\nmid = {{ git = \"{mid_url}\", rev = \"{mid_sha}\" }}\n\
2013 leaf = {{ git = \"{leaf_url}\", rev = \"{leaf_sha}\" }}\n"
2014 ),
2015 );
2016
2017 let report = install(&cfg(&project)).unwrap();
2018 assert_eq!(report.packages.len(), 2);
2019 assert_eq!((report.direct, report.transitive), (2, 0));
2020 assert_eq!(report.packages[0].requested_by, MANIFEST_REQUESTER);
2022 assert_eq!(Lockfile::read(project.lock_path()).unwrap().pkg.len(), 2);
2023 }
2024
2025 #[test]
2026 fn install_conflicting_transitive_spec_fails() {
2027 let (leaf, mid) = (TempDir::new().unwrap(), TempDir::new().unwrap());
2028 let (leaf_url, leaf_sha, mid_url, mid_sha) = leaf_and_mid(&leaf, &mid);
2029
2030 let dir = TempDir::new().unwrap();
2033 let project = project_in(dir.path());
2034 write_file(
2035 project.manifest_path(),
2036 &format!(
2037 "[package]\nname = \"root\"\nversion = \"0.1.0\"\n\n\
2038 [deps]\nleaf = {{ git = \"{leaf_url}\", rev = \"{leaf_sha}\", entry = \".\" }}\n\
2039 mid = {{ git = \"{mid_url}\", rev = \"{mid_sha}\" }}\n"
2040 ),
2041 );
2042
2043 let err = install(&cfg(&project)).unwrap_err();
2044 assert!(
2045 matches!(&err, PkgError::DepConflict { name, first, second }
2046 if name == "leaf" && first == MANIFEST_REQUESTER && second == "mid"),
2047 "expected DepConflict for 'leaf', got: {err:?}"
2048 );
2049 assert!(
2050 !project.lock_path().exists(),
2051 "lockfile must not be written on conflict"
2052 );
2053 }
2054
2055 #[test]
2058 fn add_creates_manifest_with_dep() {
2059 let dir = TempDir::new().unwrap();
2060 let project = project_in(dir.path());
2061
2062 let report = add(
2063 &cfg(&project),
2064 AddSpec {
2065 tag: Some("v1.0.0".to_string()),
2066 ..AddSpec::new("mylib", "https://github.com/x/mylib")
2067 },
2068 )
2069 .unwrap();
2070 assert_eq!(report.outcome, AddOutcome::Added);
2071 assert!(report.manifest_created);
2072
2073 let manifest = Manifest::from_path(project.manifest_path()).unwrap();
2074 assert!(manifest.deps.contains_key("mylib"), "dep must be present");
2075 let dep = &manifest.deps["mylib"];
2076 assert_eq!(dep.git, "https://github.com/x/mylib");
2077 assert_eq!(dep.tag.as_deref(), Some("v1.0.0"));
2078 assert!(dep.rev.is_none());
2079 assert!(dep.branch.is_none());
2080 let expected = std::fs::canonicalize(dir.path()).unwrap();
2082 assert_eq!(
2083 manifest.package.name,
2084 expected.file_name().unwrap().to_string_lossy()
2085 );
2086 }
2087
2088 #[test]
2089 fn add_to_existing_manifest_preserves_other_deps() {
2090 let dir = TempDir::new().unwrap();
2091 let project = project_in(dir.path());
2092
2093 write_file(
2094 project.manifest_path(),
2095 "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n\
2096 [deps]\nexisting = { git = \"https://github.com/a/b\", branch = \"main\" }\n",
2097 );
2098
2099 let report = add(
2100 &cfg(&project),
2101 AddSpec {
2102 rev: Some("abc1234567890123456789012345678901234567890".to_string()),
2103 ..AddSpec::new("newdep", "https://github.com/x/newdep")
2104 },
2105 )
2106 .unwrap();
2107 assert_eq!(report.outcome, AddOutcome::Added);
2108 assert!(!report.manifest_created);
2109
2110 let manifest = Manifest::from_path(project.manifest_path()).unwrap();
2111 assert_eq!(manifest.deps.len(), 2, "both deps must be present");
2112 assert!(manifest.deps.contains_key("existing"));
2113 assert!(manifest.deps.contains_key("newdep"));
2114 }
2115
2116 #[test]
2117 fn add_existing_name_reports_replaced() {
2118 let dir = TempDir::new().unwrap();
2119 let project = project_in(dir.path());
2120 add(
2121 &cfg(&project),
2122 AddSpec::new("lib", "https://github.com/x/lib"),
2123 )
2124 .unwrap();
2125 let report = add(
2126 &cfg(&project),
2127 AddSpec::new("lib", "https://github.com/y/lib"),
2128 )
2129 .unwrap();
2130 assert_eq!(report.outcome, AddOutcome::Replaced);
2131 let manifest = Manifest::from_path(project.manifest_path()).unwrap();
2132 assert_eq!(manifest.deps["lib"].git, "https://github.com/y/lib");
2133 }
2134
2135 #[test]
2136 fn add_rejects_multiple_ref_fields() {
2137 let dir = TempDir::new().unwrap();
2138 let project = project_in(dir.path());
2139
2140 let result = add(
2141 &cfg(&project),
2142 AddSpec {
2143 tag: Some("v1.0.0".to_string()),
2144 rev: Some("abc123".to_string()),
2145 ..AddSpec::new("lib", "https://github.com/x/lib")
2146 },
2147 );
2148 assert!(
2149 matches!(result, Err(PkgError::Validation { .. })),
2150 "tag + rev together must be rejected: {result:?}"
2151 );
2152 }
2153
2154 #[test]
2160 fn set_dep_tag_preserves_inline_layout() {
2161 let toml = "[package]\nname = \"x\"\nversion = \"0.1.0\"\n\n\
2162 [deps]\nfoo = { git = \"https://example.com/foo\", tag = \"v1.0.0\" }\n";
2163 let mut doc: toml_edit::DocumentMut = toml.parse().unwrap();
2164 set_dep_tag(&mut doc, "foo", "v1.0.5").unwrap();
2165 let out = doc.to_string();
2166 assert!(
2167 out.contains("tag = \"v1.0.5\""),
2168 "new tag must be written:\n{out}"
2169 );
2170 assert!(
2171 out.contains("git = \"https://example.com/foo\""),
2172 "git URL preserved"
2173 );
2174 }
2175
2176 #[test]
2177 fn update_prefix_pin_refreshes_lock_without_mutating_manifest() {
2178 let remote = TempDir::new().unwrap();
2179 init_repo_with_commit(remote.path());
2180 add_tag(remote.path(), "v1.0.0");
2181 add_tag(remote.path(), "v1.0.1");
2182 add_tag(remote.path(), "v1.0.5");
2183 add_tag(remote.path(), "v1.1.0");
2184
2185 let dir = TempDir::new().unwrap();
2186 let project = project_in(dir.path());
2187 let url = format!("file://{}", remote.path().display());
2188 let original = manifest_with_dep(&url, "tag = \"v1.0\"");
2189 write_file(project.manifest_path(), &original);
2190
2191 let report = update(&cfg(&project), UpdateOpts::default()).unwrap();
2192
2193 assert_eq!(report.entries.len(), 1);
2194 assert!(matches!(
2195 &report.entries[0].1,
2196 UpdateOutcome::PrefixResolved { pin, resolved }
2197 if pin == "v1.0" && resolved == "v1.0.5"
2198 ));
2199 assert!(!report.manifest_modified);
2200 assert!(
2201 report.install.is_some(),
2202 "prefix pin must trigger re-install"
2203 );
2204
2205 let after = std::fs::read_to_string(project.manifest_path()).unwrap();
2207 assert_eq!(
2208 after, original,
2209 "prefix pin manifest must not be rewritten:\n{after}"
2210 );
2211
2212 let lf = Lockfile::read(project.lock_path()).unwrap();
2214 assert_eq!(lf.pkg.len(), 1, "one locked package");
2215 assert_eq!(
2216 lf.pkg[0].tag.as_deref(),
2217 Some("v1.0.5"),
2218 "lock must record resolved concrete tag"
2219 );
2220 }
2221
2222 #[test]
2223 fn update_dry_run_leaves_manifest_unmodified() {
2224 let remote = TempDir::new().unwrap();
2225 init_repo_with_commit(remote.path());
2226 add_tag(remote.path(), "v1.0.0");
2227 add_tag(remote.path(), "v1.0.5");
2228
2229 let dir = TempDir::new().unwrap();
2230 let project = project_in(dir.path());
2231 let url = format!("file://{}", remote.path().display());
2232 let original = manifest_with_dep(&url, "tag = \"v1.0\"");
2233 write_file(project.manifest_path(), &original);
2234
2235 let report = update(
2236 &cfg(&project),
2237 UpdateOpts {
2238 dry_run: true,
2239 ..Default::default()
2240 },
2241 )
2242 .unwrap();
2243
2244 assert_eq!(report.entries.len(), 1);
2245 assert!(report.install.is_none(), "dry-run must not install");
2246 assert!(!report.manifest_modified);
2247 let after = std::fs::read_to_string(project.manifest_path()).unwrap();
2248 assert_eq!(after, original, "dry-run must not modify manifest");
2249 assert!(!project.lock_path().exists(), "dry-run must not write lock");
2250 }
2251
2252 #[test]
2253 fn update_exact_pin_without_force_is_noop() {
2254 let remote = TempDir::new().unwrap();
2255 init_repo_with_commit(remote.path());
2256 add_tag(remote.path(), "v1.0.0");
2257 add_tag(remote.path(), "v1.0.5");
2258
2259 let dir = TempDir::new().unwrap();
2260 let project = project_in(dir.path());
2261 let url = format!("file://{}", remote.path().display());
2262 write_file(
2263 project.manifest_path(),
2264 &manifest_with_dep(&url, "tag = \"v1.0.0\""),
2265 );
2266
2267 let report = update(&cfg(&project), UpdateOpts::default()).unwrap();
2268
2269 assert!(matches!(&report.entries[0].1, UpdateOutcome::Skipped(_)));
2270 assert!(report.install.is_none(), "all-skipped must not install");
2271 let after = std::fs::read_to_string(project.manifest_path()).unwrap();
2272 assert!(
2273 after.contains("tag = \"v1.0.0\""),
2274 "exact pin must remain v1.0.0 without force:\n{after}"
2275 );
2276 }
2277
2278 #[test]
2279 fn update_exact_pin_with_force_bumps_to_latest() {
2280 let remote = TempDir::new().unwrap();
2281 init_repo_with_commit(remote.path());
2282 add_tag(remote.path(), "v1.0.0");
2283 add_tag(remote.path(), "v1.0.5");
2284 add_tag(remote.path(), "v2.0.0");
2285
2286 let dir = TempDir::new().unwrap();
2287 let project = project_in(dir.path());
2288 let url = format!("file://{}", remote.path().display());
2289 write_file(
2290 project.manifest_path(),
2291 &manifest_with_dep(&url, "tag = \"v1.0.0\""),
2292 );
2293
2294 let report = update(
2295 &cfg(&project),
2296 UpdateOpts {
2297 force: true,
2298 ..Default::default()
2299 },
2300 )
2301 .unwrap();
2302
2303 assert!(matches!(
2304 &report.entries[0].1,
2305 UpdateOutcome::TagBumped { old, new } if old == "v1.0.0" && new == "v2.0.0"
2306 ));
2307 assert!(report.manifest_modified);
2308 assert!(report.install.is_some());
2309 let after = std::fs::read_to_string(project.manifest_path()).unwrap();
2310 assert!(
2311 after.contains("tag = \"v2.0.0\""),
2312 "force must bump exact pin to global max v2.0.0:\n{after}"
2313 );
2314 }
2315
2316 #[test]
2317 fn update_unknown_name_returns_error() {
2318 let dir = TempDir::new().unwrap();
2319 let project = project_in(dir.path());
2320 write_file(
2321 project.manifest_path(),
2322 "[package]\nname = \"test\"\nversion = \"0.1.0\"\n",
2323 );
2324
2325 let result = update(
2326 &cfg(&project),
2327 UpdateOpts {
2328 name: Some("nonexistent".to_string()),
2329 ..Default::default()
2330 },
2331 );
2332 assert!(
2333 matches!(result, Err(PkgError::UnknownDep { ref name, .. }) if name == "nonexistent"),
2334 "unknown dep name must return UnknownDep: {result:?}"
2335 );
2336 }
2337
2338 #[test]
2341 fn clean_all_removes_cache() {
2342 let dir = TempDir::new().unwrap();
2343 let project = project_in(dir.path());
2344 let cache_dir = project.pkg_dir().cache();
2345 let git_dir = cache_dir.join("git/example.com/org/repo");
2346 std::fs::create_dir_all(&git_dir).unwrap();
2347 std::fs::write(git_dir.join("sentinel"), "data").unwrap();
2348
2349 let report = clean(&cfg(&project), true).unwrap();
2350
2351 assert_eq!(report, CleanReport::CacheRemoved);
2352 assert!(!cache_dir.exists(), "cache directory must be removed");
2353 }
2354
2355 #[test]
2356 fn clean_all_on_empty_dir_is_noop() {
2357 let dir = TempDir::new().unwrap();
2358 let report = clean(&cfg(&project_in(dir.path())), true).unwrap();
2360 assert_eq!(report, CleanReport::StaleRemoved { removed: 0 });
2361 }
2362
2363 #[test]
2364 fn clean_without_lockfile_is_noop() {
2365 let dir = TempDir::new().unwrap();
2366 let report = clean(&cfg(&project_in(dir.path())), false).unwrap();
2367 assert_eq!(report, CleanReport::NoLockfile);
2368 }
2369
2370 #[test]
2371 fn clean_removes_stale_sha_dirs_only() {
2372 let dir = TempDir::new().unwrap();
2373 let project = project_in(dir.path());
2374 let git_base = project.pkg_dir().cache().join("git/gh.com/org/repo");
2375
2376 let sha_in_use = "a".repeat(40);
2377 let sha_stale = "b".repeat(40);
2378
2379 std::fs::create_dir_all(git_base.join(&sha_in_use)).unwrap();
2380 std::fs::create_dir_all(git_base.join(&sha_stale)).unwrap();
2381
2382 let lf = Lockfile {
2384 version: 1,
2385 pkg: vec![LockedPkg {
2386 name: "lib".to_string(),
2387 source: "git+https://gh.com/org/repo".to_string(),
2388 tag: None,
2389 rev: None,
2390 branch: None,
2391 sha: sha_in_use.clone(),
2392 entry: PathBuf::from("."),
2393 patch_dir: None,
2394 patch_base: None,
2395 }],
2396 };
2397 lf.write(project.lock_path()).unwrap();
2398
2399 let report = clean(&cfg(&project), false).unwrap();
2400
2401 assert_eq!(report, CleanReport::StaleRemoved { removed: 1 });
2402 assert!(
2403 git_base.join(&sha_in_use).exists(),
2404 "in-use SHA dir must be retained"
2405 );
2406 assert!(
2407 !git_base.join(&sha_stale).exists(),
2408 "stale SHA dir must be removed"
2409 );
2410 }
2411
2412 #[test]
2415 fn relative_path_sibling_dirs() {
2416 let tmp = TempDir::new().unwrap();
2417 let from_dir = tmp.path().join("a/b");
2418 let to_dir = tmp.path().join("a/c/d");
2419 std::fs::create_dir_all(&from_dir).unwrap();
2420 std::fs::create_dir_all(&to_dir).unwrap();
2421
2422 let rel = relative_path(&from_dir, &to_dir).unwrap();
2423 assert_eq!(rel, PathBuf::from("../c/d"));
2425 }
2426
2427 #[test]
2428 fn relative_path_vendored_to_cache() {
2429 let tmp = TempDir::new().unwrap();
2430 let vendored = tmp.path().join(".mlua-pkgs/vendored");
2431 let entry = tmp
2432 .path()
2433 .join(".mlua-pkgs/cache/git/gh.com/org/repo/aaaa1234/src");
2434 std::fs::create_dir_all(&vendored).unwrap();
2435 std::fs::create_dir_all(&entry).unwrap();
2436
2437 let rel = relative_path(&vendored, &entry).unwrap();
2438 assert!(
2439 rel.starts_with(".."),
2440 "must navigate up from vendored first"
2441 );
2442 assert!(
2443 rel.to_string_lossy().contains("cache"),
2444 "must contain 'cache' segment"
2445 );
2446 }
2447
2448 #[test]
2451 fn entry_rel_to_pkg_subdir() {
2452 let cache = PathBuf::from("/tmp/repo");
2453 let entry = PathBuf::from("/tmp/repo/src");
2454 assert_eq!(entry_rel_to_pkg(&cache, &entry), PathBuf::from("src"));
2455 }
2456
2457 #[test]
2458 fn entry_rel_to_pkg_root() {
2459 let cache = PathBuf::from("/tmp/repo");
2460 let entry = PathBuf::from("/tmp/repo");
2461 assert_eq!(entry_rel_to_pkg(&cache, &entry), PathBuf::from("."));
2462 }
2463}