1use std::borrow::Cow;
2use std::collections::BTreeMap;
3use std::path::{Path, PathBuf};
4
5use serde::de::{IgnoredAny, MapAccess, Visitor};
6use serde::{Deserialize, Deserializer};
7use tracing::{debug, info_span, warn};
8
9use uv_fs::{Simplified, created_time};
10use uv_macros::DeserializeUntagged;
11
12use crate::git_info::{Commit, Tags};
13use crate::glob::cluster_globs;
14use crate::timestamp::Timestamp;
15
16#[derive(Debug, thiserror::Error)]
17pub enum CacheInfoError {
18 #[error("Failed to parse cache keys in `{}`", _0.user_display())]
19 PyprojectToml(PathBuf, #[source] Box<toml::de::Error>),
20 #[error("Failed to parse glob patterns for `cache-keys`: {0}")]
21 Glob(#[from] globwalk::GlobError),
22 #[error(transparent)]
23 Io(#[from] std::io::Error),
24}
25
26#[derive(Default, Debug, Clone, Hash, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
29#[serde(rename_all = "kebab-case")]
30pub struct CacheInfo {
31 timestamp: Option<Timestamp>,
36 commit: Option<Commit>,
38 tags: Option<Tags>,
40 #[serde(default)]
42 env: BTreeMap<String, Option<String>>,
43 #[serde(default)]
45 directories: BTreeMap<Cow<'static, str>, Option<DirectoryTimestamp>>,
46}
47
48impl CacheInfo {
49 pub fn from_timestamp(timestamp: Timestamp) -> Self {
51 Self {
52 timestamp: Some(timestamp),
53 ..Self::default()
54 }
55 }
56
57 pub fn from_path(path: &Path) -> Result<Self, CacheInfoError> {
59 let metadata = fs_err::metadata(path)?;
60 if metadata.is_file() {
61 Ok(Self::from_file(path)?)
62 } else {
63 Self::from_directory(path)
64 }
65 }
66
67 pub fn from_directory(directory: &Path) -> Result<Self, CacheInfoError> {
69 let mut commit = None;
70 let mut tags = None;
71 let mut last_changed: Option<(PathBuf, Timestamp)> = None;
72 let mut directories = BTreeMap::new();
73 let mut env = BTreeMap::new();
74
75 let pyproject_path = directory.join("pyproject.toml");
77 let cache_keys = if let Ok(contents) = fs_err::read_to_string(&pyproject_path) {
78 let pyproject_toml =
79 info_span!("toml::from_str cache keys", path = %pyproject_path.display())
80 .in_scope(|| toml::from_str::<PyProjectToml>(&contents))
81 .map_err(|err| CacheInfoError::PyprojectToml(pyproject_path, Box::new(err)))?;
82 pyproject_toml
83 .tool
84 .and_then(|tool| tool.uv)
85 .and_then(|tool_uv| tool_uv.cache_keys)
86 } else {
87 None
88 };
89
90 let cache_keys = cache_keys.unwrap_or_else(|| {
92 vec![
93 CacheKey::Path(Cow::Borrowed("pyproject.toml")),
94 CacheKey::Path(Cow::Borrowed("setup.py")),
95 CacheKey::Path(Cow::Borrowed("setup.cfg")),
96 CacheKey::Directory {
97 dir: Cow::Borrowed("src"),
98 },
99 ]
100 });
101
102 let mut globs = vec![];
104 for cache_key in cache_keys {
105 match cache_key {
106 CacheKey::Path(file) | CacheKey::File { file } => {
107 if file
108 .as_ref()
109 .chars()
110 .any(|c| matches!(c, '*' | '?' | '[' | '{'))
111 {
112 globs.push(file);
114 continue;
115 }
116
117 let path = directory.join(file.as_ref());
119 let metadata = match path.metadata() {
120 Ok(metadata) => metadata,
121 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
122 continue;
123 }
124 Err(err) => {
125 warn!("Failed to read metadata for file: {err}");
126 continue;
127 }
128 };
129 if !metadata.is_file() {
130 warn!(
131 "Expected file for cache key, but found directory: {}",
132 path.display()
133 );
134 continue;
135 }
136 let timestamp = Timestamp::from_metadata(&metadata);
137 if last_changed.as_ref().is_none_or(|(_, prev_timestamp)| {
138 *prev_timestamp < Timestamp::from_metadata(&metadata)
139 }) {
140 last_changed = Some((path, timestamp));
141 }
142 }
143 CacheKey::Directory { dir } => {
144 let path = directory.join(dir.as_ref());
146 let metadata = match path.metadata() {
147 Ok(metadata) => metadata,
148 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
149 directories.insert(dir, None);
150 continue;
151 }
152 Err(err) => {
153 warn!("Failed to read metadata for directory: {err}");
154 continue;
155 }
156 };
157 if !metadata.is_dir() {
158 warn!(
159 "Expected directory for cache key, but found file: {}",
160 path.display()
161 );
162 continue;
163 }
164
165 if let Ok(created) = created_time(&path, &metadata) {
166 directories.insert(
168 dir,
169 Some(DirectoryTimestamp::Timestamp(Timestamp::from(created))),
170 );
171 } else {
172 cfg_select! {
174 unix => {
175 use std::os::unix::fs::MetadataExt;
176 directories.insert(
177 dir,
178 Some(DirectoryTimestamp::Inode(metadata.ino())),
179 );
180 },
181 _ => {
182 warn!(
183 "Failed to read creation time for directory: `{}`",
184 path.display()
185 );
186 },
187 }
188 }
189 }
190 CacheKey::Git {
191 git: GitPattern::Bool(true),
192 } => match Commit::from_repository(directory) {
193 Ok(commit_info) => commit = Some(commit_info),
194 Err(err) => {
195 debug!("Failed to read the current commit: {err}");
196 }
197 },
198 CacheKey::Git {
199 git: GitPattern::Set(set),
200 } => {
201 if set.commit.unwrap_or(false) {
202 match Commit::from_repository(directory) {
203 Ok(commit_info) => commit = Some(commit_info),
204 Err(err) => {
205 debug!("Failed to read the current commit: {err}");
206 }
207 }
208 }
209 if set.tags.unwrap_or(false) {
210 match Tags::from_repository(directory) {
211 Ok(tags_info) => tags = Some(tags_info),
212 Err(err) => {
213 debug!("Failed to read the current tags: {err}");
214 }
215 }
216 }
217 }
218 CacheKey::Git {
219 git: GitPattern::Bool(false),
220 } => {}
221 CacheKey::Environment { env: var } => {
222 let value = std::env::var(&var).ok();
223 env.insert(var, value);
224 }
225 }
226 }
227
228 if !globs.is_empty() {
230 for (glob_base, glob_patterns) in cluster_globs(&globs) {
231 let walker = globwalk::GlobWalkerBuilder::from_patterns(
232 directory.join(glob_base),
233 &glob_patterns,
234 )
235 .file_type(globwalk::FileType::FILE | globwalk::FileType::SYMLINK)
236 .build()?;
237 for entry in walker {
238 let entry = match entry {
239 Ok(entry) => entry,
240 Err(err) => {
241 warn!("Failed to read glob entry: {err}");
242 continue;
243 }
244 };
245 let metadata = if entry.path_is_symlink() {
246 match fs_err::metadata(entry.path()) {
248 Ok(metadata) => metadata,
249 Err(err) => {
250 warn!("Failed to resolve symlink for glob entry: {err}");
251 continue;
252 }
253 }
254 } else {
255 match entry.metadata() {
256 Ok(metadata) => metadata,
257 Err(err) => {
258 warn!("Failed to read metadata for glob entry: {err}");
259 continue;
260 }
261 }
262 };
263 if !metadata.is_file() {
264 if !entry.path_is_symlink() {
265 warn!(
267 "Expected file for cache key, but found directory: {}",
268 entry.path().display()
269 );
270 }
271 continue;
272 }
273 let timestamp = Timestamp::from_metadata(&metadata);
274 if last_changed.as_ref().is_none_or(|(_, prev_timestamp)| {
275 *prev_timestamp < Timestamp::from_metadata(&metadata)
276 }) {
277 last_changed = Some((entry.into_path(), timestamp));
278 }
279 }
280 }
281 }
282
283 let timestamp = if let Some((path, timestamp)) = last_changed {
284 debug!(
285 "Computed cache info: {timestamp:?}, {commit:?}, {tags:?}, {env:?}, {directories:?}. Most recently modified: {}",
286 path.user_display()
287 );
288 Some(timestamp)
289 } else {
290 None
291 };
292
293 Ok(Self {
294 timestamp,
295 commit,
296 tags,
297 env,
298 directories,
299 })
300 }
301
302 pub fn from_file(path: impl AsRef<Path>) -> std::io::Result<Self> {
305 let metadata = fs_err::metadata(path.as_ref())?;
306 let timestamp = Timestamp::from_metadata(&metadata);
307 Ok(Self {
308 timestamp: Some(timestamp),
309 ..Self::default()
310 })
311 }
312
313 pub fn is_empty(&self) -> bool {
315 self.timestamp.is_none()
316 && self.commit.is_none()
317 && self.tags.is_none()
318 && self.env.is_empty()
319 && self.directories.is_empty()
320 }
321}
322
323#[derive(Debug, Deserialize)]
325#[serde(rename_all = "kebab-case")]
326struct PyProjectToml {
327 tool: Option<Tool>,
328}
329
330#[derive(Debug, Deserialize)]
331#[serde(rename_all = "kebab-case")]
332struct Tool {
333 uv: Option<ToolUv>,
334}
335
336#[derive(Debug, Deserialize)]
337#[serde(rename_all = "kebab-case")]
338struct ToolUv {
339 cache_keys: Option<Vec<CacheKey>>,
340}
341
342#[derive(Debug, Clone)]
343#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
344#[cfg_attr(
345 feature = "schemars",
346 schemars(untagged, rename_all = "kebab-case", deny_unknown_fields)
347)]
348pub enum CacheKey {
349 Path(Cow<'static, str>),
351 File { file: Cow<'static, str> },
353 Directory { dir: Cow<'static, str> },
355 Git { git: GitPattern },
357 Environment { env: String },
359}
360
361impl<'de> Deserialize<'de> for CacheKey {
362 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
363 where
364 D: Deserializer<'de>,
365 {
366 #[derive(Deserialize)]
367 #[serde(field_identifier, rename_all = "lowercase")]
368 enum Field {
369 File,
370 Dir,
371 Git,
372 Env,
373 }
374
375 struct CacheKeyVisitor;
376
377 impl<'de> Visitor<'de> for CacheKeyVisitor {
378 type Value = CacheKey;
379
380 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
381 formatter.write_str("a path or a cache key table")
382 }
383
384 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
385 where
386 E: serde::de::Error,
387 {
388 Ok(CacheKey::Path(Cow::Owned(value.to_owned())))
389 }
390
391 fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
392 where
393 E: serde::de::Error,
394 {
395 Ok(CacheKey::Path(Cow::Owned(value)))
396 }
397
398 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
399 where
400 A: MapAccess<'de>,
401 {
402 let value = match map.next_key()? {
403 Some(Field::File) => CacheKey::File {
404 file: map.next_value()?,
405 },
406 Some(Field::Dir) => CacheKey::Directory {
407 dir: map.next_value()?,
408 },
409 Some(Field::Git) => CacheKey::Git {
410 git: map.next_value()?,
411 },
412 Some(Field::Env) => CacheKey::Environment {
413 env: map.next_value()?,
414 },
415 None => return Err(serde::de::Error::custom("empty cache key table")),
416 };
417 if map.next_key::<IgnoredAny>()?.is_some() {
418 return Err(serde::de::Error::custom("extra cache key field"));
419 }
420 Ok(value)
421 }
422 }
423
424 deserializer.deserialize_any(CacheKeyVisitor)
425 }
426}
427
428#[derive(Debug, Clone, DeserializeUntagged)]
429#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
430#[cfg_attr(
431 feature = "schemars",
432 schemars(untagged, rename_all = "kebab-case", deny_unknown_fields)
433)]
434#[serde(expecting = "a boolean or a Git cache key table")]
435pub enum GitPattern {
436 #[untagged(bool)]
437 Bool(bool),
438 #[untagged(map)]
439 Set(GitSet),
440}
441
442#[derive(Debug, Clone, serde::Deserialize)]
443#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
444#[serde(rename_all = "kebab-case", deny_unknown_fields)]
445pub struct GitSet {
446 commit: Option<bool>,
447 tags: Option<bool>,
448}
449
450#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
452#[serde(untagged, rename_all = "kebab-case", deny_unknown_fields)]
453enum DirectoryTimestamp {
454 Timestamp(Timestamp),
455 Inode(u64),
456}
457
458#[cfg(test)]
459mod tests {
460 use super::{CacheKey, ToolUv};
461
462 #[test]
463 fn cache_key_forms() -> Result<(), toml::de::Error> {
464 let settings: ToolUv = toml::from_str(
465 r#"
466 cache-keys = [
467 "Cargo.lock",
468 { file = "**/*.toml" },
469 { dir = "src" },
470 { git = true },
471 { git = { commit = true, tags = false } },
472 { git = {} },
473 { env = "UV_CACHE_INFO" },
474 ]
475 "#,
476 )?;
477 insta::assert_debug_snapshot!(settings.cache_keys, @r#"
478 Some(
479 [
480 Path(
481 "Cargo.lock",
482 ),
483 File {
484 file: "**/*.toml",
485 },
486 Directory {
487 dir: "src",
488 },
489 Git {
490 git: Bool(
491 true,
492 ),
493 },
494 Git {
495 git: Set(
496 GitSet {
497 commit: Some(
498 true,
499 ),
500 tags: Some(
501 false,
502 ),
503 },
504 ),
505 },
506 Git {
507 git: Set(
508 GitSet {
509 commit: None,
510 tags: None,
511 },
512 ),
513 },
514 Environment {
515 env: "UV_CACHE_INFO",
516 },
517 ],
518 )
519 "#);
520 Ok(())
521 }
522
523 #[test]
524 fn cache_key_extra_field() {
525 insta::assert_snapshot!(toml::from_str::<CacheKey>(r#"file = "Cargo.lock"
526 env = "UV_CACHE_INFO"
527 "#).unwrap_err(), @r#"
528 TOML parse error at line 1, column 1
529 |
530 1 | file = "Cargo.lock"
531 | ^
532 extra cache key field
533 "#);
534 }
535}
536
537#[cfg(all(test, unix))]
538mod tests_unix {
539 use anyhow::Result;
540
541 use super::{CacheInfo, Timestamp};
542
543 #[test]
544 fn test_cache_info_symlink_resolve() -> Result<()> {
545 let dir = tempfile::tempdir()?;
546 let dir = dir.path().join("dir");
547 fs_err::create_dir_all(&dir)?;
548
549 let write_manifest = |cache_key: &str| {
550 fs_err::write(
551 dir.join("pyproject.toml"),
552 format!(
553 r#"
554 [tool.uv]
555 cache-keys = [
556 "{cache_key}"
557 ]
558 "#
559 ),
560 )
561 };
562
563 let touch = |path: &str| -> Result<_> {
564 let path = dir.join(path);
565 fs_err::create_dir_all(path.parent().unwrap())?;
566 fs_err::write(&path, "")?;
567 Ok(Timestamp::from_metadata(&path.metadata()?))
568 };
569
570 let cache_timestamp = || -> Result<_> { Ok(CacheInfo::from_directory(&dir)?.timestamp) };
571
572 write_manifest("x/**")?;
573 assert_eq!(cache_timestamp()?, None);
574 let y = touch("x/y")?;
575 assert_eq!(cache_timestamp()?, Some(y));
576 let z = touch("x/z")?;
577 assert_eq!(cache_timestamp()?, Some(z));
578
579 let a = touch("../a")?;
581 fs_err::os::unix::fs::symlink(dir.join("../a"), dir.join("x/a"))?;
582 assert_eq!(cache_timestamp()?, Some(a));
583
584 let c = touch("../b/c")?;
586 fs_err::os::unix::fs::symlink(dir.join("../b"), dir.join("x/b"))?;
587 assert_eq!(cache_timestamp()?, Some(a));
588
589 write_manifest("x/y")?;
591 assert_eq!(cache_timestamp()?, Some(y));
592 write_manifest("x/a")?;
593 assert_eq!(cache_timestamp()?, Some(a));
594 write_manifest("x/b/c")?;
595 assert_eq!(cache_timestamp()?, Some(c));
596
597 write_manifest("x/*b*")?;
599 assert_eq!(cache_timestamp()?, None);
600
601 Ok(())
602 }
603}