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