Skip to main content

dev_prune/adapters/
unity.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Unity imported-asset cache adapter.
5//
6// Unity keeps its imported-asset database in `Library/` beside the project: compressed
7// textures, converted meshes, the asset database itself, shader caches. `Temp/` is the
8// editor's scratch space. Both are written only by the editor, both regenerate from the
9// committed sources the next time it opens the project, and both are in Unity's own
10// recommended `.gitignore`. On an asset-heavy project `Library/` is routinely larger
11// than the sources it was imported from.
12//
13// Detection is `ProjectSettings/ProjectVersion.txt`, which every Unity project has had
14// since well before 2018; `Packages/manifest.json` does not go back that far, so it is
15// not the anchor. The one thing the manifest check cannot see is a *running* editor,
16// because the idle gate is Git-based and an open editor commits nothing, so the
17// verification also refuses while `Temp/UnityLockfile` exists, which is the editor's
18// own "this project is open" marker. Deleting `Library/` under a live editor corrupts
19// the asset database instead of clearing a cache.
20//
21// Opt-in, and held to `build_idle_days`: getting `Library/` back means the editor
22// re-importing every asset, which on a large project is a long sit, not a download.
23
24use super::{BloatDir, EnforcePolicy, PackageManager, dir_size};
25use anyhow::{Result, anyhow};
26use std::fs;
27use std::path::Path;
28
29/// Present at every Unity project's root, whatever the editor version.
30const MANIFEST: &str = "ProjectSettings/ProjectVersion.txt";
31
32/// The editor's own "this project is open in an editor right now" marker.
33const EDITOR_LOCK: &str = "Temp/UnityLockfile";
34
35/// The editor's cache directories: the imported-asset database and its scratch space.
36const CACHE_DIRS: &[&str] = &["Library", "Temp"];
37
38/// Unity imported-asset cache adapter. Opt-in; see the module comment.
39pub struct Unity;
40
41impl PackageManager for Unity {
42    fn name(&self) -> &'static str {
43        "unity"
44    }
45
46    fn detect(&self, path: &Path) -> bool {
47        path.join(MANIFEST).is_file()
48    }
49
50    fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
51        CACHE_DIRS
52            .iter()
53            .map(|name| path.join(name))
54            .filter(|dir| dir.is_dir())
55            .map(|dir| BloatDir {
56                name: dir
57                    .file_name()
58                    .unwrap_or_default()
59                    .to_string_lossy()
60                    .into_owned(),
61                size_bytes: dir_size(&dir),
62                path: dir,
63                shared_bytes: 0,
64            })
65            .collect()
66    }
67
68    /// Two refusals. `ProjectVersion.txt` must still read as one (every version of the
69    /// file opens with `m_EditorVersion:`), because it is what the editor will rebuild
70    /// `Library/` from. And `Temp/UnityLockfile` must not exist: it means the editor
71    /// has the project open *now*, which the Git-based idle gate cannot see, and
72    /// deleting the asset database under a live editor corrupts it.
73    fn enforce_lockfile(&self, path: &Path, _policy: EnforcePolicy) -> Result<()> {
74        let manifest = path.join(MANIFEST);
75        let content = fs::read_to_string(&manifest).map_err(|e| {
76            anyhow!("`{MANIFEST}` could not be read ({e}): nothing to re-import the assets from.")
77        })?;
78        if !content.contains("m_EditorVersion:") {
79            return Err(anyhow!(
80                "`{MANIFEST}` has no `m_EditorVersion:` line: refusing to treat the \
81                 imported-asset cache as regenerable from it."
82            ));
83        }
84        if path.join(EDITOR_LOCK).exists() {
85            return Err(anyhow!(
86                "`{EDITOR_LOCK}` exists: the Unity editor has this project open, and \
87                 the asset database is not deletable under it. Close the editor first."
88            ));
89        }
90        Ok(())
91    }
92
93    fn restore(&self, _path: &Path, _timeout: std::time::Duration) -> Result<()> {
94        println!(
95            "Unity's imported-asset cache regenerates when the editor next opens the \
96             project; expect a full re-import on the first open"
97        );
98        Ok(())
99    }
100
101    fn lockfiles(&self) -> &'static [&'static str] {
102        &[MANIFEST]
103    }
104
105    fn opt_in(&self) -> bool {
106        true
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use std::path::PathBuf;
114    use tempfile::tempdir;
115
116    /// A project root holding a plausible `ProjectSettings/ProjectVersion.txt`.
117    fn project(dir: &Path) -> PathBuf {
118        fs::create_dir_all(dir.join("ProjectSettings")).unwrap();
119        fs::write(
120            dir.join(MANIFEST),
121            "m_EditorVersion: 2022.3.10f1\nm_EditorVersionWithRevision: 2022.3.10f1 (ff3792e53c62)\n",
122        )
123        .unwrap();
124        dir.to_path_buf()
125    }
126
127    fn claimed(project: &Path) -> Vec<String> {
128        Unity
129            .bloat_dirs(project)
130            .into_iter()
131            .map(|b| b.name)
132            .collect()
133    }
134
135    #[test]
136    fn detects_on_the_project_manifest() {
137        let dir = tempdir().unwrap();
138        assert!(!Unity.detect(dir.path()));
139        project(dir.path());
140        assert!(Unity.detect(dir.path()));
141    }
142
143    #[test]
144    fn the_asset_database_and_scratch_space_are_claimed() {
145        let dir = tempdir().unwrap();
146        let root = project(dir.path());
147        fs::create_dir(root.join("Library")).unwrap();
148        fs::write(root.join("Library").join("ArtifactDB"), "cache").unwrap();
149        fs::create_dir(root.join("Temp")).unwrap();
150
151        assert_eq!(claimed(&root), vec!["Library", "Temp"]);
152    }
153
154    #[test]
155    fn nothing_else_beside_the_manifest_is_claimed() {
156        // `Assets/` is the sources the cache is imported *from*; claiming anything by
157        // a name not in the list would be deleting somebody's work.
158        let dir = tempdir().unwrap();
159        let root = project(dir.path());
160        fs::create_dir(root.join("Assets")).unwrap();
161
162        assert!(claimed(&root).is_empty());
163    }
164
165    #[test]
166    fn a_missing_or_bogus_manifest_is_refused() {
167        let dir = tempdir().unwrap();
168        let policy = EnforcePolicy::default();
169        assert!(Unity.enforce_lockfile(dir.path(), policy).is_err());
170        fs::create_dir_all(dir.path().join("ProjectSettings")).unwrap();
171        fs::write(dir.path().join(MANIFEST), "hello there").unwrap();
172        assert!(Unity.enforce_lockfile(dir.path(), policy).is_err());
173        project(dir.path());
174        assert!(Unity.enforce_lockfile(dir.path(), policy).is_ok());
175    }
176
177    #[test]
178    fn an_open_editor_is_refused() {
179        // The idle gate reads Git, and an open editor commits nothing, so this is the
180        // one place a live editor can be caught.
181        let dir = tempdir().unwrap();
182        let root = project(dir.path());
183        let policy = EnforcePolicy::default();
184        assert!(Unity.enforce_lockfile(&root, policy).is_ok());
185        fs::create_dir(root.join("Temp")).unwrap();
186        fs::write(root.join(EDITOR_LOCK), "").unwrap();
187        assert!(Unity.enforce_lockfile(&root, policy).is_err());
188    }
189
190    #[test]
191    fn unity_is_opt_in() {
192        assert!(Unity.opt_in());
193    }
194}