1 /*
2 * Copyright (C) 2025 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package com.android.tools.metalava.testing
18
19 import com.android.tools.lint.checks.infrastructure.TestFile
20 import java.io.File
21 import java.nio.file.Files
22 import org.junit.rules.TemporaryFolder
23 import org.junit.rules.TestRule
24 import org.junit.runner.Description
25 import org.junit.runners.model.Statement
26
27 /**
28 * Cache [TestFile]s such that they can be easily shared between tests.
29 *
30 * The primary purpose of this is to amortize the cost of creating jars for testing by sharing the
31 * resulting [File] across multiple tests at the cost of increased disk usage. However, it can be
32 * used to cache, and share, any [TestFile] whose [TestFile.createFile] will create a [File] in the
33 * supplied `targetDir`.
34 *
35 * Must call [open] before calling [TestFile.createFile] on any [TestFile] returned from [cache].
36 */
37 class TestFileCache : AutoCloseable {
38 /** The directory in which [File]s are created. */
39 private lateinit var cacheDir: File
40
41 /** Map from [TestFile.targetRelativePath] to [CachedTestFile] wrapper. */
42 private val targetRelativePathToTestFile = mutableMapOf<String, CachedTestFile>()
43
44 /**
45 * Open the cache for use by supplying it with a [cacheDir] in which it will create the [File]s.
46 *
47 * This can be called after calling [cache] to cache a [TestFile] but must be called before any
48 * of those [TestFile]s are created. Separating this from initialization allows the cache to be
49 * created and used to cache [TestFile]s without creating a directory that may never be used and
50 * may not be easily cleaned up.
51 *
52 * It is the responsibility of the caller of [open] to ensure that [cacheDir] is deleted, either
53 * by calling [close] or some other way, e.g. by passing in a directory created by
54 * [TemporaryFolder].
55 */
opennull56 fun open(cacheDir: File): TestFileCache {
57 if (::cacheDir.isInitialized) {
58 error(
59 "Cannot open cache with `$cacheDir` as it has already been opened with `${this.cacheDir}"
60 )
61 }
62 this.cacheDir = cacheDir
63 return this
64 }
65
66 /**
67 * Cache a [TestFile] for sharing.
68 *
69 * This returns a [TestFile] wrapper around [testFile] that will ensure that [testFile] will
70 * only be created once within this cache. The [TestFile.targetRelativePath] must uniquely
71 * identify [testFile] among all [TestFile]s added to this cache.
72 */
cachenull73 fun cache(testFile: TestFile): TestFile {
74 val relative =
75 testFile.targetRelativePath
76 ?: error("Cannot cache $testFile as it has no targetRelativePath")
77 val existing = targetRelativePathToTestFile[relative]
78 if (existing != null) {
79 if (existing.underlying !== testFile) {
80 error(
81 "Cannot cache $testFile as its targetRelativePath of `$relative` clashes with $existing"
82 )
83 }
84 return existing
85 }
86
87 val cached = CachedTestFile(testFile).apply { to(testFile.targetRelativePath) }
88 targetRelativePathToTestFile[relative] = cached
89 return cached
90 }
91
closenull92 override fun close() {
93 if (::cacheDir.isInitialized) {
94 cacheDir.deleteRecursively()
95 }
96 }
97
createUnderlyingFilenull98 private fun createUnderlyingFile(underlyingFile: TestFile): TestFile {
99 if (!::cacheDir.isInitialized) {
100 error("Cannot create underlying file as cache has not yet been opened")
101 }
102 val file = underlyingFile.createFile(cacheDir)
103 // Compute the path of the newly created file relative to the directory.
104 val relative = file.relativeTo(cacheDir).path
105
106 // Create a TestFile that when created will create a symbolic link to `file` in the same
107 // directory relative to the target directory.
108 return file.toTestFile().to(relative)
109 }
110
111 /** Wrapper around a [TestFile] that ensures that only one instance will be created. */
112 private inner class CachedTestFile(val underlying: TestFile) : TestFile() {
113 /** Lazily create the [File] from [underlying] and wrap in a [TestFile]. */
<lambda>null114 private val file by lazy { createUnderlyingFile(underlying) }
115
createFilenull116 override fun createFile(targetDir: File?): File =
117 // Create the underlying file if necessary and then create a symbolic link to it.
118 file.createFile(targetDir)
119 }
120 }
121
122 /**
123 * A [TestRule] that is intended to be used with `@ClassRule` to create a per test class cache of
124 * [TestFile]s.
125 *
126 * It can also be used with `@Rule` to create a per test cache if a [TestFile] might be created
127 * multiple times within the same test.
128 */
129 class TestFileCacheRule : TestRule {
130 /**
131 * Create the [TestFileCache] immediately so it can be used to cache [TestFile]s before entering
132 * the [Statement] returned by [apply].
133 */
134 private var _cache: TestFileCache? = TestFileCache()
135
136 /** Get the cache to use. */
137 val cache: TestFileCache
138 get() = _cache ?: error("Cannot access cache outside test run")
139
140 override fun apply(base: Statement, description: Description): Statement {
141 return object : Statement() {
142 override fun evaluate() {
143 try {
144 val cacheDir = Files.createTempDirectory("test-file-cache").toFile()
145 _cache!!.open(cacheDir).use { base.evaluate() }
146 } finally {
147 // Prevent the cache from being accessed after it has been used.
148 _cache = null
149 }
150 }
151 }
152 }
153 }
154
155 /**
156 * Cache this [TestFile] in [cache].
157 *
158 * Returns a [TestFile] that will only create this file once and will use a symbolic link to add it
159 * to the `targetDir` of [TestFile.createFile].
160 */
cacheInnull161 fun TestFile.cacheIn(cache: TestFileCache) = cache.cache(this)
162
163 /**
164 * Cache this [TestFile] in [cacheRule]'s [TestFileCacheRule.cache].
165 *
166 * Returns a [TestFile] that will only create this file once and will use a symbolic link to add it
167 * to the `targetDir` of [TestFile.createFile].
168 */
169 fun TestFile.cacheIn(cacheRule: TestFileCacheRule) = cacheRule.cache.cache(this)
170