1use std::borrow::Cow;
2use std::collections::BTreeMap;
3use std::path::{Path, PathBuf};
4
5use serde::Deserialize;
6use tracing::{debug, info_span, warn};
7
8use uv_fs::Simplified;
9
10use crate::git_info::{Commit, Tags};
11use crate::glob::cluster_globs;
12use crate::timestamp::Timestamp;
13
14#[derive(Debug, thiserror::Error)]
15pub enum CacheInfoError {
16 #[error("Failed to parse glob patterns for `cache-keys`: {0}")]
17 Glob(#[from] globwalk::GlobError),
18 #[error(transparent)]
19 Io(#[from] std::io::Error),
20}
21
22#[derive(Default, Debug, Clone, Hash, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
25#[serde(rename_all = "kebab-case")]
26pub struct CacheInfo {
27 timestamp: Option<Timestamp>,
32 commit: Option<Commit>,
34 tags: Option<Tags>,
36 #[serde(default)]
38 env: BTreeMap<String, Option<String>>,
39 #[serde(default)]
41 directories: BTreeMap<Cow<'static, str>, Option<DirectoryTimestamp>>,
42}
43
44impl CacheInfo {
45 pub fn from_timestamp(timestamp: Timestamp) -> Self {
47 Self {
48 timestamp: Some(timestamp),
49 ..Self::default()
50 }
51 }
52
53 pub fn from_path(path: &Path) -> Result<Self, CacheInfoError> {
55 let metadata = fs_err::metadata(path)?;
56 if metadata.is_file() {
57 Ok(Self::from_file(path)?)
58 } else {
59 Self::from_directory(path)
60 }
61 }
62
63 pub fn from_directory(directory: &Path) -> Result<Self, CacheInfoError> {
65 let mut commit = None;
66 let mut tags = None;
67 let mut last_changed: Option<(PathBuf, Timestamp)> = None;
68 let mut directories = BTreeMap::new();
69 let mut env = BTreeMap::new();
70
71 let pyproject_path = directory.join("pyproject.toml");
73 let cache_keys = if let Ok(contents) = fs_err::read_to_string(&pyproject_path) {
74 let result = info_span!("toml::from_str cache keys", path = %pyproject_path.display())
75 .in_scope(|| toml::from_str::<PyProjectToml>(&contents));
76 if let Ok(pyproject_toml) = result {
77 pyproject_toml
78 .tool
79 .and_then(|tool| tool.uv)
80 .and_then(|tool_uv| tool_uv.cache_keys)
81 } else {
82 None
83 }
84 } else {
85 None
86 };
87
88 let cache_keys = cache_keys.unwrap_or_else(|| {
90 vec![
91 CacheKey::Path(Cow::Borrowed("pyproject.toml")),
92 CacheKey::Path(Cow::Borrowed("setup.py")),
93 CacheKey::Path(Cow::Borrowed("setup.cfg")),
94 CacheKey::Directory {
95 dir: Cow::Borrowed("src"),
96 },
97 ]
98 });
99
100 let mut globs = vec![];
102 for cache_key in cache_keys {
103 match cache_key {
104 CacheKey::Path(file) | CacheKey::File { file } => {
105 if file
106 .as_ref()
107 .chars()
108 .any(|c| matches!(c, '*' | '?' | '[' | '{'))
109 {
110 globs.push(file);
112 continue;
113 }
114
115 let path = directory.join(file.as_ref());
117 let metadata = match path.metadata() {
118 Ok(metadata) => metadata,
119 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
120 continue;
121 }
122 Err(err) => {
123 warn!("Failed to read metadata for file: {err}");
124 continue;
125 }
126 };
127 if !metadata.is_file() {
128 warn!(
129 "Expected file for cache key, but found directory: `{}`",
130 path.display()
131 );
132 continue;
133 }
134 let timestamp = Timestamp::from_metadata(&metadata);
135 if last_changed.as_ref().is_none_or(|(_, prev_timestamp)| {
136 *prev_timestamp < Timestamp::from_metadata(&metadata)
137 }) {
138 last_changed = Some((path, timestamp));
139 }
140 }
141 CacheKey::Directory { dir } => {
142 let path = directory.join(dir.as_ref());
144 let metadata = match path.metadata() {
145 Ok(metadata) => metadata,
146 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
147 directories.insert(dir, None);
148 continue;
149 }
150 Err(err) => {
151 warn!("Failed to read metadata for directory: {err}");
152 continue;
153 }
154 };
155 if !metadata.is_dir() {
156 warn!(
157 "Expected directory for cache key, but found file: `{}`",
158 path.display()
159 );
160 continue;
161 }
162
163 if let Ok(created) = metadata.created() {
164 directories.insert(
166 dir,
167 Some(DirectoryTimestamp::Timestamp(Timestamp::from(created))),
168 );
169 } else {
170 cfg_select! {
172 unix => {
173 use std::os::unix::fs::MetadataExt;
174 directories.insert(
175 dir,
176 Some(DirectoryTimestamp::Inode(metadata.ino())),
177 );
178 },
179 _ => {
180 warn!(
181 "Failed to read creation time for directory: `{}`",
182 path.display()
183 );
184 },
185 }
186 }
187 }
188 CacheKey::Git {
189 git: GitPattern::Bool(true),
190 } => match Commit::from_repository(directory) {
191 Ok(commit_info) => commit = Some(commit_info),
192 Err(err) => {
193 debug!("Failed to read the current commit: {err}");
194 }
195 },
196 CacheKey::Git {
197 git: GitPattern::Set(set),
198 } => {
199 if set.commit.unwrap_or(false) {
200 match Commit::from_repository(directory) {
201 Ok(commit_info) => commit = Some(commit_info),
202 Err(err) => {
203 debug!("Failed to read the current commit: {err}");
204 }
205 }
206 }
207 if set.tags.unwrap_or(false) {
208 match Tags::from_repository(directory) {
209 Ok(tags_info) => tags = Some(tags_info),
210 Err(err) => {
211 debug!("Failed to read the current tags: {err}");
212 }
213 }
214 }
215 }
216 CacheKey::Git {
217 git: GitPattern::Bool(false),
218 } => {}
219 CacheKey::Environment { env: var } => {
220 let value = std::env::var(&var).ok();
221 env.insert(var, value);
222 }
223 }
224 }
225
226 if !globs.is_empty() {
228 for (glob_base, glob_patterns) in cluster_globs(&globs) {
229 let walker = globwalk::GlobWalkerBuilder::from_patterns(
230 directory.join(glob_base),
231 &glob_patterns,
232 )
233 .file_type(globwalk::FileType::FILE | globwalk::FileType::SYMLINK)
234 .build()?;
235 for entry in walker {
236 let entry = match entry {
237 Ok(entry) => entry,
238 Err(err) => {
239 warn!("Failed to read glob entry: {err}");
240 continue;
241 }
242 };
243 let metadata = if entry.path_is_symlink() {
244 match fs_err::metadata(entry.path()) {
246 Ok(metadata) => metadata,
247 Err(err) => {
248 warn!("Failed to resolve symlink for glob entry: {err}");
249 continue;
250 }
251 }
252 } else {
253 match entry.metadata() {
254 Ok(metadata) => metadata,
255 Err(err) => {
256 warn!("Failed to read metadata for glob entry: {err}");
257 continue;
258 }
259 }
260 };
261 if !metadata.is_file() {
262 if !entry.path_is_symlink() {
263 warn!(
265 "Expected file for cache key, but found directory: `{}`",
266 entry.path().display()
267 );
268 }
269 continue;
270 }
271 let timestamp = Timestamp::from_metadata(&metadata);
272 if last_changed.as_ref().is_none_or(|(_, prev_timestamp)| {
273 *prev_timestamp < Timestamp::from_metadata(&metadata)
274 }) {
275 last_changed = Some((entry.into_path(), timestamp));
276 }
277 }
278 }
279 }
280
281 let timestamp = if let Some((path, timestamp)) = last_changed {
282 debug!(
283 "Computed cache info: {timestamp:?}, {commit:?}, {tags:?}, {env:?}, {directories:?}. Most recently modified: {}",
284 path.user_display()
285 );
286 Some(timestamp)
287 } else {
288 None
289 };
290
291 Ok(Self {
292 timestamp,
293 commit,
294 tags,
295 env,
296 directories,
297 })
298 }
299
300 pub fn from_file(path: impl AsRef<Path>) -> std::io::Result<Self> {
303 let metadata = fs_err::metadata(path.as_ref())?;
304 let timestamp = Timestamp::from_metadata(&metadata);
305 Ok(Self {
306 timestamp: Some(timestamp),
307 ..Self::default()
308 })
309 }
310
311 pub fn is_empty(&self) -> bool {
313 self.timestamp.is_none()
314 && self.commit.is_none()
315 && self.tags.is_none()
316 && self.env.is_empty()
317 && self.directories.is_empty()
318 }
319}
320
321#[derive(Debug, Deserialize)]
323#[serde(rename_all = "kebab-case")]
324struct PyProjectToml {
325 tool: Option<Tool>,
326}
327
328#[derive(Debug, Deserialize)]
329#[serde(rename_all = "kebab-case")]
330struct Tool {
331 uv: Option<ToolUv>,
332}
333
334#[derive(Debug, Deserialize)]
335#[serde(rename_all = "kebab-case")]
336struct ToolUv {
337 cache_keys: Option<Vec<CacheKey>>,
338}
339
340#[derive(Debug, Clone, serde::Deserialize)]
341#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
342#[serde(untagged, rename_all = "kebab-case", deny_unknown_fields)]
343pub enum CacheKey {
344 Path(Cow<'static, str>),
346 File { file: Cow<'static, str> },
348 Directory { dir: Cow<'static, str> },
350 Git { git: GitPattern },
352 Environment { env: String },
354}
355
356#[derive(Debug, Clone, serde::Deserialize)]
357#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
358#[serde(untagged, rename_all = "kebab-case", deny_unknown_fields)]
359pub enum GitPattern {
360 Bool(bool),
361 Set(GitSet),
362}
363
364#[derive(Debug, Clone, serde::Deserialize)]
365#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
366#[serde(rename_all = "kebab-case", deny_unknown_fields)]
367pub struct GitSet {
368 commit: Option<bool>,
369 tags: Option<bool>,
370}
371
372#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
374#[serde(untagged, rename_all = "kebab-case", deny_unknown_fields)]
375enum DirectoryTimestamp {
376 Timestamp(Timestamp),
377 Inode(u64),
378}
379
380#[cfg(all(test, unix))]
381mod tests_unix {
382 use anyhow::Result;
383
384 use super::{CacheInfo, Timestamp};
385
386 #[test]
387 fn test_cache_info_symlink_resolve() -> Result<()> {
388 let dir = tempfile::tempdir()?;
389 let dir = dir.path().join("dir");
390 fs_err::create_dir_all(&dir)?;
391
392 let write_manifest = |cache_key: &str| {
393 fs_err::write(
394 dir.join("pyproject.toml"),
395 format!(
396 r#"
397 [tool.uv]
398 cache-keys = [
399 "{cache_key}"
400 ]
401 "#
402 ),
403 )
404 };
405
406 let touch = |path: &str| -> Result<_> {
407 let path = dir.join(path);
408 fs_err::create_dir_all(path.parent().unwrap())?;
409 fs_err::write(&path, "")?;
410 Ok(Timestamp::from_metadata(&path.metadata()?))
411 };
412
413 let cache_timestamp = || -> Result<_> { Ok(CacheInfo::from_directory(&dir)?.timestamp) };
414
415 write_manifest("x/**")?;
416 assert_eq!(cache_timestamp()?, None);
417 let y = touch("x/y")?;
418 assert_eq!(cache_timestamp()?, Some(y));
419 let z = touch("x/z")?;
420 assert_eq!(cache_timestamp()?, Some(z));
421
422 let a = touch("../a")?;
424 fs_err::os::unix::fs::symlink(dir.join("../a"), dir.join("x/a"))?;
425 assert_eq!(cache_timestamp()?, Some(a));
426
427 let c = touch("../b/c")?;
429 fs_err::os::unix::fs::symlink(dir.join("../b"), dir.join("x/b"))?;
430 assert_eq!(cache_timestamp()?, Some(a));
431
432 write_manifest("x/y")?;
434 assert_eq!(cache_timestamp()?, Some(y));
435 write_manifest("x/a")?;
436 assert_eq!(cache_timestamp()?, Some(a));
437 write_manifest("x/b/c")?;
438 assert_eq!(cache_timestamp()?, Some(c));
439
440 write_manifest("x/*b*")?;
442 assert_eq!(cache_timestamp()?, None);
443
444 Ok(())
445 }
446}