Skip to main content

dev_prune/adapters/
unreal.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Unreal Engine derived-data adapter.
5//
6// An Unreal project is anchored by a `<Name>.uproject` file at its root: a JSON
7// document the launcher, the editor and the build tool all read. Beside it the engine
8// writes `DerivedDataCache/` (compiled shaders, cooked texture formats) and
9// `Intermediate/` (generated project files, UnrealBuildTool object output). Both are
10// derived from the committed sources and both are in Epic's own recommended
11// `.gitignore`; the editor and UnrealBuildTool rebuild them on the next open and the
12// next build.
13//
14// The claim is those two names at the project root only. Never `Saved/`, which holds
15// config, screenshots and local saved games nothing can regenerate; never `Binaries/`,
16// whose loss turns "open the editor" into "compile the engine modules first"; and
17// never a plugin's own `Intermediate/`, because the claim stops at the root. Two
18// honest caveats: the *default* Derived Data Cache lives in a machine-global
19// directory, so a project that never set a local DDC has little of it here to
20// reclaim; and there is no fixed manifest name, so `devp doctor` reports that no
21// single file identifies this manager rather than pretending one does.
22//
23// Opt-in, and held to `build_idle_days`: shaders recompile and object files rebuild,
24// and on a large project that is hours, not a download.
25
26use super::{BloatDir, EnforcePolicy, PackageManager, dir_size};
27use anyhow::{Result, anyhow};
28use std::fs;
29use std::path::{Path, PathBuf};
30
31/// The derived directories at the project root. Nothing else, and never `Saved/`.
32const CACHE_DIRS: &[&str] = &["DerivedDataCache", "Intermediate"];
33
34/// The `<Name>.uproject` at this root, if there is exactly the kind of file the
35/// editor itself would open. Case-insensitive on the extension because Windows is.
36fn uproject_in(path: &Path) -> Option<PathBuf> {
37    fs::read_dir(path)
38        .ok()?
39        .flatten()
40        .map(|entry| entry.path())
41        .find(|p| {
42            p.is_file()
43                && p.extension()
44                    .is_some_and(|ext| ext.eq_ignore_ascii_case("uproject"))
45        })
46}
47
48/// Unreal Engine derived-data adapter. Opt-in; see the module comment.
49pub struct Unreal;
50
51impl PackageManager for Unreal {
52    fn name(&self) -> &'static str {
53        "unreal"
54    }
55
56    fn detect(&self, path: &Path) -> bool {
57        uproject_in(path).is_some()
58    }
59
60    fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
61        CACHE_DIRS
62            .iter()
63            .map(|name| path.join(name))
64            .filter(|dir| dir.is_dir())
65            .map(|dir| BloatDir {
66                name: dir
67                    .file_name()
68                    .unwrap_or_default()
69                    .to_string_lossy()
70                    .into_owned(),
71                size_bytes: dir_size(&dir),
72                path: dir,
73                shared_bytes: 0,
74            })
75            .collect()
76    }
77
78    /// The `.uproject` must still read as one: every version of the format is a JSON
79    /// object carrying a `"FileVersion"` key, and a file without it is whatever it
80    /// is, not a project the editor can rebuild derived data for. Running the editor
81    /// or UnrealBuildTool to find out would start the build this pass exists to
82    /// postpone.
83    fn enforce_lockfile(&self, path: &Path, _policy: EnforcePolicy) -> Result<()> {
84        let manifest = uproject_in(path).ok_or_else(|| {
85            anyhow!("no `.uproject` file here: nothing to rebuild the derived data from.")
86        })?;
87        let content = fs::read_to_string(&manifest).map_err(|e| {
88            anyhow!(
89                "`{}` could not be read ({e}): nothing to rebuild the derived data from.",
90                manifest.display()
91            )
92        })?;
93        if !content.contains("\"FileVersion\"") {
94            return Err(anyhow!(
95                "`{}` has no `\"FileVersion\"` key: refusing to treat the derived data \
96                 as regenerable from it.",
97                manifest.display()
98            ));
99        }
100        Ok(())
101    }
102
103    fn restore(&self, _path: &Path, _timeout: std::time::Duration) -> Result<()> {
104        println!(
105            "Unreal rebuilds DerivedDataCache/ and Intermediate/ on the next editor \
106             open and build; for a C++ project, regenerate project files first \
107             (right-click the .uproject, Generate Project Files) because the generated \
108             solution lived in Intermediate/"
109        );
110        Ok(())
111    }
112
113    /// Empty on purpose: the manifest is `<Name>.uproject`, a name this adapter cannot
114    /// know ahead of time, and `devp doctor` says so rather than guessing.
115    fn lockfiles(&self) -> &'static [&'static str] {
116        &[]
117    }
118
119    fn opt_in(&self) -> bool {
120        true
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use std::path::PathBuf;
128    use tempfile::tempdir;
129
130    /// A project root holding a plausible `Demo.uproject`.
131    fn project(dir: &Path) -> PathBuf {
132        fs::write(
133            dir.join("Demo.uproject"),
134            "{\n\t\"FileVersion\": 3,\n\t\"EngineAssociation\": \"5.3\",\n\t\"Category\": \"\"\n}\n",
135        )
136        .unwrap();
137        dir.to_path_buf()
138    }
139
140    fn claimed(project: &Path) -> Vec<String> {
141        Unreal
142            .bloat_dirs(project)
143            .into_iter()
144            .map(|b| b.name)
145            .collect()
146    }
147
148    #[test]
149    fn detects_on_any_uproject_file() {
150        let dir = tempdir().unwrap();
151        assert!(!Unreal.detect(dir.path()));
152        project(dir.path());
153        assert!(Unreal.detect(dir.path()));
154    }
155
156    #[test]
157    fn a_uproject_directory_is_not_a_manifest() {
158        // The editor opens a file; a directory that happens to end in `.uproject`
159        // is somebody's naming choice, not a project.
160        let dir = tempdir().unwrap();
161        fs::create_dir(dir.path().join("Demo.uproject")).unwrap();
162        assert!(!Unreal.detect(dir.path()));
163    }
164
165    #[test]
166    fn only_the_derived_directories_are_claimed() {
167        // `Saved/` holds config and local saved games nothing regenerates, and
168        // `Binaries/` is what lets the editor open without a compile: neither is
169        // this adapter's to claim, and the test is what keeps that true.
170        let dir = tempdir().unwrap();
171        let root = project(dir.path());
172        fs::create_dir(root.join("DerivedDataCache")).unwrap();
173        fs::write(root.join("DerivedDataCache").join("DDC.udd"), "cache").unwrap();
174        fs::create_dir(root.join("Intermediate")).unwrap();
175        fs::create_dir(root.join("Saved")).unwrap();
176        fs::create_dir(root.join("Binaries")).unwrap();
177        fs::create_dir(root.join("Content")).unwrap();
178
179        assert_eq!(claimed(&root), vec!["DerivedDataCache", "Intermediate"]);
180    }
181
182    #[test]
183    fn a_missing_or_bogus_manifest_is_refused() {
184        let dir = tempdir().unwrap();
185        let policy = EnforcePolicy::default();
186        assert!(Unreal.enforce_lockfile(dir.path(), policy).is_err());
187        fs::write(dir.path().join("Demo.uproject"), "hello there").unwrap();
188        assert!(Unreal.enforce_lockfile(dir.path(), policy).is_err());
189        project(dir.path());
190        assert!(Unreal.enforce_lockfile(dir.path(), policy).is_ok());
191    }
192
193    #[test]
194    fn unreal_is_opt_in() {
195        assert!(Unreal.opt_in());
196    }
197}