1use crate::io::{DefaultProjectIo, DirEntry, IoTrait};
2use crate::traits::AbortCheck;
3use crate::unity_project::find_legacy_assets::collect_legacy_assets;
4use crate::utils::{PathBufExt, walk_dir_relative};
5use crate::version::DependencyRange;
6use crate::{PackageInfo, UnityProject, unity_compatible};
7use crate::{PackageInstaller, io};
8use either::Either;
9use futures::future::{join, join_all};
10use futures::prelude::*;
11use indexmap::IndexSet;
12use log::debug;
13use std::collections::hash_map::Entry;
14use std::collections::{HashMap, HashSet, VecDeque};
15use std::future::ready;
16use std::marker::PhantomData;
17use std::path::{Path, PathBuf};
18use std::pin::pin;
19use std::sync::OnceLock;
20
21#[derive(Debug)]
29pub struct PendingProjectChanges<'env> {
30 pub(crate) package_changes: HashMap<Box<str>, PackageChange<'env>>,
31
32 pub(crate) remove_legacy_files: Vec<(Box<Path>, &'env str)>,
33 pub(crate) remove_legacy_folders: Vec<(Box<Path>, &'env str)>,
34
35 pub(crate) conflicts: HashMap<Box<str>, ConflictInfo>,
36}
37
38#[derive(Debug)]
39pub enum PackageChange<'env> {
40 Install(Install<'env>),
41 Remove(Remove<'env>),
42}
43
44impl<'env> PackageChange<'env> {
45 pub fn as_install(&self) -> Option<&Install<'env>> {
46 match self {
47 PackageChange::Install(x) => Some(x),
48 PackageChange::Remove(_) => None,
49 }
50 }
51
52 pub fn as_remove(&self) -> Option<&Remove<'env>> {
53 match self {
54 PackageChange::Install(_) => None,
55 PackageChange::Remove(x) => Some(x),
56 }
57 }
58}
59
60#[derive(Debug)]
61pub struct Install<'env> {
62 package: Option<PackageInfo<'env>>,
63 add_to_locked: bool,
64 to_dependencies: Option<DependencyRange>,
65}
66
67impl<'env> Install<'env> {
68 pub fn install_package(&self) -> Option<PackageInfo<'env>> {
69 self.package
70 }
71
72 pub fn is_adding_to_locked(&self) -> bool {
73 self.add_to_locked
74 }
75
76 pub fn to_dependencies(&self) -> Option<&DependencyRange> {
77 self.to_dependencies.as_ref()
78 }
79}
80
81#[derive(Debug)]
82pub struct Remove<'env> {
83 reason: RemoveReason,
84 _phantom: PhantomData<&'env ()>,
85}
86
87impl Remove<'_> {
88 pub fn reason(&self) -> RemoveReason {
89 self.reason
90 }
91}
92
93#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
94pub enum RemoveReason {
95 Requested,
96 Legacy,
97 Unused,
98}
99
100#[derive(Debug, Default)]
101pub struct ConflictInfo {
102 conflicts_packages: Vec<Box<str>>,
103 conflicts_with_unity: bool,
104 unlocked_names: Vec<Box<str>>,
106}
107
108impl ConflictInfo {
109 pub fn conflicting_packages(&self) -> &[Box<str>] {
110 self.conflicts_packages.as_slice()
111 }
112
113 pub fn conflicts_with_unity(&self) -> bool {
114 self.conflicts_with_unity
115 }
116
117 pub fn unlocked_names(&self) -> &[Box<str>] {
118 self.unlocked_names.as_slice()
119 }
120}
121
122pub(crate) struct Builder<'env> {
123 package_changes: HashMap<Box<str>, PackageChange<'env>>,
124 conflicts: HashMap<Box<str>, ConflictInfo>,
125}
126
127impl<'env> Builder<'env> {
128 pub fn new() -> Self {
129 Self {
130 package_changes: HashMap::new(),
131 conflicts: HashMap::new(),
132 }
133 }
134
135 pub fn add_to_dependencies(&mut self, name: Box<str>, version: DependencyRange) -> &mut Self {
136 match self.package_changes.entry(name) {
137 Entry::Occupied(mut e) => match e.get_mut() {
138 PackageChange::Install(e) => {
139 if e.to_dependencies.is_none() {
140 e.to_dependencies = Some(version);
141 } else {
142 panic!("INTERNAL ERROR: already add_to_dependencies");
143 }
144 }
145 PackageChange::Remove(_) => {
146 panic!("INTERNAL ERROR: add_to_dependencies for removed");
147 }
148 },
149 Entry::Vacant(e) => {
150 e.insert(PackageChange::Install(Install {
151 package: None,
152 add_to_locked: false,
153 to_dependencies: Some(version),
154 }));
155 }
156 }
157
158 self
159 }
160
161 pub fn install_to_locked(&mut self, info: PackageInfo<'env>) -> &mut Self {
162 match self.package_changes.entry(info.name().into()) {
163 Entry::Occupied(mut e) => match e.get_mut() {
164 PackageChange::Install(e) => {
165 if e.package.is_none() {
166 e.package = Some(info);
167 e.add_to_locked = true;
168 } else {
169 panic!("INTERNAL ERROR: already install");
170 }
171 }
172 PackageChange::Remove(_) => {
173 panic!("INTERNAL ERROR: install for removed");
174 }
175 },
176 Entry::Vacant(e) => {
177 e.insert(PackageChange::Install(Install {
178 package: Some(info),
179 add_to_locked: true,
180 to_dependencies: None,
181 }));
182 }
183 }
184 self
185 }
186
187 pub fn install_already_locked(&mut self, info: PackageInfo<'env>) -> &mut Self {
188 match self.package_changes.entry(info.name().into()) {
189 Entry::Occupied(mut e) => match e.get_mut() {
190 PackageChange::Install(e) => {
191 if e.package.is_none() {
192 e.package = Some(info);
193 e.add_to_locked = false;
194 } else {
195 panic!("INTERNAL ERROR: already install");
196 }
197 }
198 PackageChange::Remove(_) => {
199 panic!("INTERNAL ERROR: install for removed");
200 }
201 },
202 Entry::Vacant(e) => {
203 e.insert(PackageChange::Install(Install {
204 package: Some(info),
205 add_to_locked: false,
206 to_dependencies: None,
207 }));
208 }
209 }
210 self
211 }
212
213 pub fn unlocked_installation_conflict(
214 &mut self,
215 name: Box<str>,
216 unlocked_name: Box<str>,
217 ) -> &mut Self {
218 self.conflicts
219 .entry(name)
220 .or_default()
221 .unlocked_names
222 .push(unlocked_name);
223 self
224 }
225
226 pub fn conflict_multiple(
227 &mut self,
228 name: Box<str>,
229 conflict: impl IntoIterator<Item = Box<str>>,
230 ) -> &mut Self {
231 self.conflicts
232 .entry(name)
233 .or_default()
234 .conflicts_packages
235 .extend(conflict);
236 self
237 }
238
239 pub fn conflicts(&mut self, name: Box<str>, conflict: Box<str>) -> &mut Self {
240 self.conflicts
241 .entry(name)
242 .or_default()
243 .conflicts_packages
244 .push(conflict);
245 self
246 }
247
248 pub fn conflicts_unity(&mut self, name: Box<str>) -> &mut Self {
249 self.conflicts.entry(name).or_default().conflicts_with_unity = true;
250 self
251 }
252
253 pub fn remove(&mut self, name: Box<str>, reason: RemoveReason) -> &mut Self {
254 match self.package_changes.entry(name) {
255 Entry::Occupied(mut e) => match e.get_mut() {
256 PackageChange::Install(_) => {
257 panic!("INTERNAL ERROR: remove for installed");
258 }
259 PackageChange::Remove(e) => {
260 if e.reason != reason {
261 panic!("INTERNAL ERROR: already remove");
262 }
263 }
264 },
265 Entry::Vacant(e) => {
266 e.insert(PackageChange::Remove(Remove {
267 reason,
268 _phantom: PhantomData,
269 }));
270 }
271 }
272 self
273 }
274
275 fn remove_unused(&mut self, name: Box<str>) -> &mut Self {
276 match self.package_changes.entry(name) {
277 Entry::Occupied(mut e) => match e.get_mut() {
278 PackageChange::Install(_) => {
279 panic!("INTERNAL ERROR: remove_unused for installed");
280 }
281 PackageChange::Remove(_) => {
282 }
284 },
285 Entry::Vacant(e) => {
286 e.insert(PackageChange::Remove(Remove {
287 reason: RemoveReason::Unused,
288 _phantom: PhantomData,
289 }));
290 }
291 }
292 self
293 }
294
295 pub(crate) fn get_dependencies(&self, name: &str) -> Option<&DependencyRange> {
296 self.package_changes
297 .get(name)
298 .and_then(|x| x.as_install())
299 .and_then(|x| x.to_dependencies.as_ref())
300 }
301
302 pub(crate) fn get_installing(&self, name: &str) -> Option<PackageInfo<'env>> {
303 self.package_changes
304 .get(name)
305 .and_then(|x| x.as_install())
306 .filter(|x| x.add_to_locked)
307 .and_then(|x| x.package)
308 }
309
310 pub(crate) fn get_all_installing(&self) -> impl Iterator<Item = PackageInfo<'env>> + '_ {
311 self.package_changes
312 .values()
313 .filter_map(|x| x.as_install())
314 .filter(|x| x.add_to_locked)
315 .filter_map(|x| x.package)
316 }
317
318 pub fn build_no_resolve(self) -> PendingProjectChanges<'env> {
319 for change in self.package_changes.values() {
320 match change {
321 PackageChange::Install(change) => {
322 if change.package.is_some() {
323 panic!("INTERNAL ERROR: install package requires resolve")
324 }
325 }
326 PackageChange::Remove(_) => {
327 panic!("INTERNAL ERROR: remove requires resolve")
328 }
329 }
330 }
331
332 PendingProjectChanges {
333 package_changes: self.package_changes,
334 conflicts: self.conflicts,
335
336 remove_legacy_files: vec![],
337 remove_legacy_folders: vec![],
338 }
339 }
340
341 pub async fn build_resolve(
342 mut self,
343 unity_project: &UnityProject,
344 ) -> PendingProjectChanges<'env> {
345 let installs = Vec::from_iter(
346 self.package_changes
347 .values()
348 .filter_map(|x| x.as_install())
349 .filter(|x| x.add_to_locked)
350 .map(|x| x.package.unwrap()),
351 );
352
353 debug!("checking for unity compatibility");
354
355 for package in installs
356 .iter()
357 .filter(|pkg| !unity_compatible(pkg.package_json(), unity_project.unity_version))
358 .map(|pkg| pkg.name().into())
359 {
360 self.conflicts_unity(package);
361 }
362
363 debug!("Finding unused packages");
364
365 self.mark_and_sweep_packages(unity_project);
366
367 debug!("Collecting legacy assets");
368
369 let legacy_assets =
370 collect_legacy_assets(&unity_project.io, &installs, unity_project).await;
371
372 debug!("Building PendingProjectChanges finished!");
373
374 PendingProjectChanges {
375 package_changes: self.package_changes,
376 conflicts: self.conflicts,
377
378 remove_legacy_files: legacy_assets.files,
379 remove_legacy_folders: legacy_assets.folders,
380 }
381 }
382
383 fn mark_and_sweep_packages(&mut self, unity_project: &UnityProject) {
384 fn mark_recursive<'a, F, I>(
385 entrypoint: impl Iterator<Item = &'a str>,
386 get_dependencies: F,
387 ) -> HashSet<&'a str>
388 where
389 F: Fn(&'a str) -> I,
390 I: Iterator<Item = &'a str>,
391 {
392 let mut mark = HashSet::from_iter(entrypoint);
393
394 if mark.is_empty() {
395 return mark;
396 }
397
398 let mut queue = mark.iter().copied().collect::<VecDeque<_>>();
399
400 while let Some(dep_name) = queue.pop_back() {
401 for dep_name in get_dependencies(dep_name) {
402 if mark.insert(dep_name) {
403 queue.push_front(dep_name);
404 }
405 }
406 }
407
408 mark
409 }
410
411 let removable = {
415 let entrypoint =
417 self.package_changes
418 .iter()
419 .filter_map(|(name, change)| match change {
420 PackageChange::Install(change) if change.add_to_locked => {
421 unity_project.get_locked(name.as_ref()).map(|x| x.name())
422 }
423 PackageChange::Install(_) => None,
425 PackageChange::Remove(_) => {
426 unity_project.get_locked(name.as_ref()).map(|x| x.name())
427 }
428 });
429
430 mark_recursive(entrypoint, |dep_name| {
431 unity_project
432 .get_locked(dep_name)
433 .into_iter()
434 .flat_map(|dep| dep.dependencies.keys())
435 .map(Box::as_ref)
436 })
437 };
438
439 debug!("removable packages: {removable:?}");
440 if removable.is_empty() {
442 return;
443 }
444
445 let installing_packages = self
447 .package_changes
448 .iter()
449 .filter(|(_, change)| change.as_install().is_some())
450 .map(|(name, _)| name.clone())
451 .collect::<Vec<_>>();
452
453 let using_packages = {
455 let unlocked_dependencies = unity_project
456 .unlocked_packages()
457 .iter()
458 .filter_map(|(_, pkg)| pkg.as_ref())
459 .flat_map(|pkg| pkg.vpm_dependencies().keys())
460 .map(Box::as_ref);
461
462 let dependencies = unity_project.dependencies().filter(|name| {
463 self.package_changes
464 .get(*name)
465 .and_then(|change| change.as_remove())
466 .is_none()
467 });
468
469 let package_changes = installing_packages.iter().map(Box::as_ref);
471
472 let entry_points = unlocked_dependencies
473 .chain(dependencies)
474 .chain(package_changes);
475
476 mark_recursive(entry_points, |dep_name| {
477 if let Some(to_install) = self
478 .package_changes
479 .get(dep_name)
480 .and_then(|change| change.as_install())
481 .and_then(|x| x.package)
482 {
483 Either::Left(to_install.vpm_dependencies().keys().map(Box::as_ref))
484 } else {
485 Either::Right(
486 unity_project
487 .get_locked(dep_name)
488 .into_iter()
489 .flat_map(|dep| dep.dependencies.keys())
490 .map(Box::as_ref),
491 )
492 }
493 })
494 };
495
496 debug!("using packages: {using_packages:?}");
497
498 for locked in unity_project.locked_packages() {
500 if !using_packages.contains(locked.name()) && removable.contains(locked.name()) {
501 self.remove_unused(locked.name().into());
502 }
503 }
504 }
505}
506
507impl PendingProjectChanges<'_> {
508 pub fn package_changes(&self) -> &HashMap<Box<str>, PackageChange<'_>> {
509 &self.package_changes
510 }
511
512 pub fn remove_legacy_files(&self) -> &[(Box<Path>, &str)] {
513 self.remove_legacy_files.as_slice()
514 }
515
516 pub fn remove_legacy_folders(&self) -> &[(Box<Path>, &str)] {
517 self.remove_legacy_folders.as_slice()
518 }
519
520 pub fn conflicts(&self) -> &HashMap<Box<str>, ConflictInfo> {
521 &self.conflicts
522 }
523}
524
525static TEMP_DIR: &str = "Temp";
526static PKG_TEMP_DIR: &str = "Temp/vrc-get";
527
528impl UnityProject {
529 pub async fn apply_pending_changes<'env, Env: PackageInstaller>(
533 &mut self,
534 env: &'env Env,
535 request: PendingProjectChanges<'env>,
536 ) -> io::Result<()> {
537 let mut installs = Vec::new();
567 let mut remove_names = Vec::new();
568 let mut remove_unlocked_names = Vec::new();
569
570 for (name, change) in &request.package_changes {
571 match change {
572 PackageChange::Install(change) => {
573 if let Some(package) = change.package {
574 installs.push(package);
575 }
576 }
577 PackageChange::Remove(_) => {
578 remove_names.push(name.as_ref());
579 }
580 }
581 }
582
583 for info in request.conflicts.values() {
584 for x in &info.unlocked_names {
585 remove_unlocked_names.push(x.as_ref());
586 }
587 }
588
589 let remove_temp_dir = format!("{PKG_TEMP_DIR}/{}", uuid::Uuid::new_v4());
591 let remove_temp_dir = Path::new(&remove_temp_dir);
592
593 self.io.create_dir_all(remove_temp_dir).await?;
594
595 move_packages_to_temp(
596 &self.io,
597 (remove_names.iter().copied())
598 .chain(installs.iter().map(|x| x.name()))
599 .chain(remove_unlocked_names.iter().copied()),
600 remove_temp_dir,
601 )
602 .await?;
603
604 for (name, change) in &request.package_changes {
606 match change {
607 PackageChange::Install(change) => {
608 if let Some(package) = change.package
609 && change.add_to_locked
610 {
611 self.manifest.add_locked(
612 package.name(),
613 package.version().clone(),
614 package.vpm_dependencies().clone(),
615 );
616 }
617
618 if let Some(version) = &change.to_dependencies {
619 self.manifest.add_dependency(name, version.clone());
620 }
621 }
622 PackageChange::Remove(_) => {}
623 }
624 }
625
626 self.manifest.remove_packages(remove_names.iter().copied());
627
628 self.save().await?;
631
632 install_packages(&self.io, env, &installs).await?;
635
636 self.io.remove_dir_all(remove_temp_dir).await.ok();
637 self.io.remove_dir_all(PKG_TEMP_DIR.as_ref()).await.ok();
638 self.io.remove_dir(TEMP_DIR.as_ref()).await.ok();
640
641 remove_assets(
644 &self.io,
645 request.remove_legacy_files.iter().map(|(p, _)| p.as_ref()),
646 request
647 .remove_legacy_folders
648 .iter()
649 .map(|(p, _)| p.as_ref()),
650 )
651 .await;
652
653 Ok(())
654 }
655}
656
657static REMOVED_FILE_PREFIX: &str = ".__removed_";
658
659async fn move_packages_to_temp<'a>(
660 io: &DefaultProjectIo,
661 names: impl Iterator<Item = &'a str>,
662 temp_dir: &Path,
663) -> io::Result<Vec<&'a str>> {
664 let mut moved = IndexSet::new();
668
669 for name in names {
670 if moved.contains(name) {
671 continue;
672 }
673
674 match move_package(io, name, temp_dir).await {
675 Ok(true) => {
676 moved.insert(name);
677 }
678 Ok(false) => {
679 }
681 Err(err) => {
682 moved.insert(name);
685 restore_remove(io, temp_dir, moved.iter().copied()).await;
686
687 return Err(err);
688 }
689 }
690 }
691
692 return Ok(moved.into_iter().collect());
693
694 async fn move_package(io: &DefaultProjectIo, name: &str, temp_dir: &Path) -> io::Result<bool> {
695 let package_dir = format!("Packages/{name}");
696 let package_dir = Path::new(&package_dir);
697 let copied_dir = temp_dir.join(name);
698
699 io.create_dir_all(&copied_dir).await?;
700 let mut iterator = pin!(walk_dir_relative(io, vec![package_dir.into()]));
701 while let Some((original, entry)) = iterator.next().await {
702 let relative = original.strip_prefix(package_dir).unwrap();
703 let mut moved = copied_dir.join(relative);
704 if entry.file_type().await?.is_dir() {
705 match io.create_dir_all(&moved).await {
706 Ok(()) => {}
707 Err(e) => {
708 log::error!(gui_toast = false; "error creating directory {}: {e}", moved.display());
709 return Err(e);
710 }
711 }
712 } else {
713 if let Some(name) = original.file_name().unwrap().to_str() {
714 moved.pop();
715 moved.push(format!("{REMOVED_FILE_PREFIX}{name}"));
716 }
717 log::trace!("move {} to {}", original.display(), moved.display());
718
719 match io.rename(&original, &moved).await {
720 Ok(()) => {}
721 Err(e) => {
722 log::error!(gui_toast = false; "error moving {} to {}: {e}", original.display(), moved.display());
724 }
725 }
726 }
727 }
728
729 match io.remove_dir_all(package_dir).await {
730 Ok(()) => {}
731 Err(err) if err.kind() == io::ErrorKind::NotFound => {
732 return Ok(false);
733 }
734 Err(err) => {
735 return Err(err);
736 }
737 }
738
739 Ok(true)
740 }
741}
742
743async fn restore_remove(io: &DefaultProjectIo, temp_dir: &Path, names: impl Iterator<Item = &str>) {
744 for name in names {
745 let package_dir = format!("Packages/{name}");
746 let package_dir = Path::new(&package_dir);
747 let temp_package_dir = temp_dir.join(name);
748 if io.metadata(&temp_package_dir).await.is_err() {
749 continue;
750 }
751
752 if io.metadata(package_dir).await.is_ok() {
753 let mut iterator = pin!(walk_dir_relative(io, vec![temp_package_dir.clone()]));
755 while let Some((original, entry)) = iterator.next().await {
756 if entry
757 .file_type()
758 .await
759 .map(|x| !x.is_dir())
760 .unwrap_or(false)
761 {
762 let relative = original.strip_prefix(&temp_package_dir).unwrap();
763 if let Some(name) = original.file_name().unwrap().to_str() {
764 let name = name.strip_prefix(REMOVED_FILE_PREFIX).unwrap_or(name);
765 let moved = package_dir.join(relative.parent().unwrap()).joined(name);
766 io.create_dir_all(moved.parent().unwrap()).await.ok();
767 io.rename(&original, &moved).await.ok();
768 }
769 }
770 }
771 } else {
772 io.rename(&temp_package_dir, package_dir).await.ok();
774
775 let mut iterator = pin!(walk_dir_relative(io, vec![package_dir.into()]));
776 while let Some((original, entry)) = iterator.next().await {
777 if entry
778 .file_type()
779 .await
780 .map(|x| !x.is_dir())
781 .unwrap_or(false)
782 && let Some(name) = original.file_name().unwrap().to_str()
783 && let Some(stripped) = name.strip_prefix(REMOVED_FILE_PREFIX)
784 {
785 let moved = original.parent().unwrap().join(stripped);
786 io.rename(&original, &moved).await.ok();
787 }
788 }
789 }
790 }
791 io.remove_dir(temp_dir).await.ok();
792 io.remove_dir(PKG_TEMP_DIR.as_ref()).await.ok();
793 io.remove_dir(TEMP_DIR.as_ref()).await.ok();
794}
795
796async fn install_packages<Env: PackageInstaller>(
797 io: &DefaultProjectIo,
798 env: &Env,
799 packages: &[PackageInfo<'_>],
800) -> io::Result<()> {
801 let abort = AbortCheck::new();
802 let mut error_store = OnceLock::new();
803
804 join_all(packages.iter().map(|package| {
806 env.install_package(io, *package, &abort).then(|x| {
807 if let Err(e) = x {
808 error_store.set(e).ok();
809 abort.abort();
810 }
811 ready(())
812 })
813 }))
814 .await;
815
816 if let Some(err) = error_store.take() {
817 return Err(err);
818 }
819
820 Ok(())
821}
822
823async fn remove_assets(
824 io: &DefaultProjectIo,
825 legacy_files: impl Iterator<Item = &Path>,
826 legacy_folders: impl Iterator<Item = &Path>,
827) {
828 join(
829 join_all(legacy_files.map(|relative| async move {
830 remove_file(io, relative).await;
831 })),
832 join_all(legacy_folders.map(|relative| async move {
833 remove_folder(io, relative).await;
834 })),
835 )
836 .await;
837
838 async fn remove_meta_file(io: &DefaultProjectIo, path: PathBuf) {
839 let mut building = path.into_os_string();
840 building.push(".meta");
841 let meta = PathBuf::from(building);
842
843 if let Some(err) = io.remove_file(&meta).await.err()
844 && !matches!(err.kind(), io::ErrorKind::NotFound)
845 {
846 log::error!("error removing legacy asset at {}: {err}", meta.display());
847 }
848 }
849
850 async fn remove_file(io: &DefaultProjectIo, path: &Path) {
851 if let Some(err) = io.remove_file(path).await.err() {
852 log::error!("error removing legacy asset at {}: {err}", path.display());
853 }
854 remove_meta_file(io, path.to_owned()).await;
855 }
856
857 async fn remove_folder(io: &DefaultProjectIo, path: &Path) {
858 if let Some(err) = io.remove_dir_all(path).await.err() {
859 log::error!("error removing legacy asset at {}: {err}", path.display());
860 }
861 remove_meta_file(io, path.to_owned()).await;
862 }
863}