1use std::{
17 ffi::OsStr,
18 path::{Component, Path, PathBuf, Prefix, PrefixComponent},
19 process::Stdio,
20 time::{SystemTime, UNIX_EPOCH},
21};
22
23use eyre::WrapErr;
24use fs4::{FileExt, TryLockError};
25use serde::{Deserialize, Serialize};
26use smol::fs;
27use tracing::{info, warn};
28use walkdir::WalkDir;
29
30pub const CLI_COMMIT: &str = env!("WATERUI_CLI_COMMIT");
32
33const BUILD_CACHE_DIR_NAME: &str = "build_cache";
34const MANAGED_BACKENDS_DIR_NAME: &str = "managed_backends";
35const SHARED_TARGET_DIR_NAME: &str = "target";
36const CONFIG_FILE_NAME: &str = "config.toml";
37const METADATA_FILE_NAME: &str = "metadata.toml";
38const CLEANUP_LOCK_FILE_NAME: &str = ".cleanup.lock";
39const LEGACY_LOCAL_WATER_DIR_NAME: &str = ".water";
40const DEFAULT_BUILD_CACHE_CLEANUP_AFTER_UNUSED_DAYS: u64 = 30;
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
43pub struct WaterConfig {
45 #[serde(default)]
47 pub build_cache: BuildCacheConfig,
48 #[serde(default)]
53 pub last_used_device: std::collections::BTreeMap<String, String>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57pub struct BuildCacheConfig {
59 #[serde(default = "default_build_cache_cleanup_after_unused_days")]
61 pub cleanup_after_unused_days: u64,
62}
63
64impl Default for BuildCacheConfig {
65 fn default() -> Self {
66 Self {
67 cleanup_after_unused_days: default_build_cache_cleanup_after_unused_days(),
68 }
69 }
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73struct CacheMetadata {
74 project_root: String,
75 cli_commit: String,
76 last_used_unix_seconds: u64,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub struct BuildCacheGcSummary {
82 pub scanned_entries: usize,
84 pub removed_entries: usize,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum BuildCacheGcOutcome {
91 Ran(BuildCacheGcSummary),
93 SkippedAlreadyRunning,
95}
96
97const fn default_build_cache_cleanup_after_unused_days() -> u64 {
98 DEFAULT_BUILD_CACHE_CLEANUP_AFTER_UNUSED_DAYS
99}
100
101#[derive(Debug, thiserror::Error)]
103#[error("Could not determine home directory")]
104pub struct HomeDirError;
105
106pub fn water_home_dir() -> Result<PathBuf, HomeDirError> {
111 let home = dirs::home_dir().ok_or(HomeDirError)?;
112 Ok(home.join(".water"))
113}
114
115pub fn water_home_dir_in(host: &crate::toolchain::Host) -> Result<PathBuf, HomeDirError> {
120 let home = host.home_dir().ok_or(HomeDirError)?;
121 Ok(home.join(".water"))
122}
123
124pub async fn ensure_global_config() -> eyre::Result<WaterConfig> {
129 let water_home = water_home_dir()?;
130 ensure_global_config_in(&water_home).await
131}
132
133pub async fn build_cache_root() -> eyre::Result<PathBuf> {
138 let water_home = water_home_dir()?;
139 let (_, cache_root) = resolved_build_cache_root_in(&water_home).await?;
140 Ok(cache_root)
141}
142
143pub async fn shared_target_dir() -> eyre::Result<PathBuf> {
166 let cache_root = build_cache_root().await?;
167 ensure_shared_target_dir_in(&cache_root).await
168}
169
170pub async fn shared_host_target_dir() -> eyre::Result<PathBuf> {
179 Ok(shared_target_dir().await?.join("host"))
180}
181
182async fn ensure_shared_target_dir_in(cache_root: &Path) -> eyre::Result<PathBuf> {
183 let target_dir = cache_root.join(SHARED_TARGET_DIR_NAME);
184 fs::create_dir_all(&target_dir).await.wrap_err_with(|| {
185 format!(
186 "Failed to create shared target dir {}",
187 target_dir.display()
188 )
189 })?;
190 write_metadata(
193 &target_dir,
194 &CacheMetadata {
195 project_root: target_dir.display().to_string(),
196 cli_commit: CLI_COMMIT.to_string(),
197 last_used_unix_seconds: now_unix_seconds()?,
198 },
199 )
200 .await?;
201 Ok(target_dir)
202}
203
204pub async fn remove_shared_target_dir() -> eyre::Result<Option<u64>> {
219 let water_home = water_home_dir()?;
220 let (_, cache_root) = resolved_build_cache_root_in(&water_home).await?;
221 remove_shared_target_dir_in(&cache_root).await
222}
223
224async fn remove_shared_target_dir_in(cache_root: &Path) -> eyre::Result<Option<u64>> {
225 let target_dir = cache_root.join(SHARED_TARGET_DIR_NAME);
226 if !target_dir.exists() {
227 return Ok(None);
228 }
229 shared_target_in_use(&target_dir).await?;
230 let bytes = directory_disk_usage(target_dir.clone()).await?;
231 fs::remove_dir_all(&target_dir).await.wrap_err_with(|| {
232 format!(
233 "Failed to remove shared target dir {}",
234 target_dir.display()
235 )
236 })?;
237 Ok(Some(bytes))
238}
239
240async fn shared_target_in_use(target_dir: &Path) -> eyre::Result<()> {
249 let target_dir = target_dir.to_path_buf();
250 smol::unblock(move || -> eyre::Result<()> {
251 let mut profile_dirs = Vec::new();
252 let mut pending = vec![(target_dir.clone(), 0usize)];
253 while let Some((dir, depth)) = pending.pop() {
254 if depth == 3 {
255 continue;
256 }
257 for entry in std::fs::read_dir(&dir)? {
258 let entry = entry?;
259 if entry.file_type()?.is_dir() {
260 let path = entry.path();
261 if path.join(".cargo-lock").exists() {
262 profile_dirs.push(path.clone());
263 }
264 pending.push((path, depth + 1));
265 }
266 }
267 }
268 for dir in &profile_dirs {
269 let lock_path = dir.join(".cargo-lock");
270 let file = std::fs::OpenOptions::new()
271 .write(true)
272 .open(&lock_path)
273 .wrap_err_with(|| format!("Failed to open {}", lock_path.display()))?;
274 match FileExt::try_lock(&file) {
275 Ok(()) => {}
276 Err(TryLockError::WouldBlock) => {
277 return Err(eyre::eyre!(
278 "the shared Cargo target {} is in use by a running build \
279 ({} is locked) — drop it once the build finishes",
280 target_dir.display(),
281 lock_path.display()
282 ));
283 }
284 Err(TryLockError::Error(error)) => {
285 return Err(eyre::Report::from(error))
286 .wrap_err_with(|| format!("Failed to lock {}", lock_path.display()));
287 }
288 }
289 }
290 Ok(())
291 })
292 .await
293}
294
295pub async fn project_build_cache_dir(project_root: &Path) -> eyre::Result<PathBuf> {
300 let project_root = canonicalize_project_root(project_root)?;
301 let cache_root = build_cache_root().await?;
302 Ok(project_build_cache_dir_in(&project_root, &cache_root))
303}
304
305pub async fn build_cache_container_for(project_root: &Path) -> eyre::Result<PathBuf> {
318 let mut trailing = Vec::new();
319 let mut existing = project_root.to_path_buf();
320 let resolved = loop {
321 if let Ok(resolved) = existing.canonicalize() {
322 break resolved;
323 }
324 let name = existing.file_name().map(std::ffi::OsString::from);
325 let parent = existing.parent().map(Path::to_path_buf);
326 let (Some(name), Some(parent)) = (name, parent) else {
327 return Err(eyre::eyre!(
328 "Failed to resolve any existing ancestor of {}",
329 project_root.display()
330 ));
331 };
332 trailing.push(name);
333 existing = parent;
334 };
335 let mut project_root = resolved;
336 for name in trailing.iter().rev() {
337 project_root.push(name);
338 }
339 let cache_root = build_cache_root().await?;
340 Ok(project_cache_container_in(&project_root, &cache_root))
341}
342
343pub async fn ensure_project_build_cache(project_root: &Path) -> eyre::Result<PathBuf> {
348 let project_root = canonicalize_project_root(project_root)?;
349 let water_home = water_home_dir()?;
350 let (config, cache_root) = resolved_build_cache_root_in(&water_home).await?;
351 if let Err(error) = spawn_build_cache_cleanup_process(&project_root).await {
352 warn!(
353 current_project_root = %project_root.display(),
354 "Failed to spawn build-cache cleanup process: {error}"
355 );
356 }
357 ensure_project_build_cache_in(&project_root, &cache_root, &config).await
358}
359
360pub async fn cleanup_stale_build_caches_for_project(
366 project_root: &Path,
367) -> eyre::Result<BuildCacheGcOutcome> {
368 let project_root = canonicalize_project_root(project_root)?;
369 let water_home = water_home_dir()?;
370 let (config, cache_root) = resolved_build_cache_root_in(&water_home).await?;
371 cleanup_stale_caches_if_idle(&cache_root, &project_root, &config).await
372}
373
374async fn spawn_build_cache_cleanup_process(project_root: &Path) -> eyre::Result<()> {
375 let current_executable = std::env::current_exe()
376 .wrap_err("Failed to resolve current water executable for build-cache cleanup")?;
377 let project_root = project_root.to_path_buf();
378
379 smol::unblock(move || -> eyre::Result<()> {
380 std::process::Command::new(¤t_executable)
381 .arg("gc")
382 .arg("build-cache")
383 .arg("--path")
384 .arg(&project_root)
385 .stdin(Stdio::null())
386 .stdout(Stdio::null())
387 .stderr(Stdio::null())
388 .spawn()
389 .map(|_| ())
390 .map_err(eyre::Report::from)
391 .wrap_err_with(|| {
392 format!(
393 "Failed to spawn build-cache cleanup process for {}",
394 project_root.display()
395 )
396 })
397 })
398 .await
399}
400
401pub async fn remove_project_build_cache(project_root: &Path) -> eyre::Result<()> {
406 let project_root = canonicalize_project_root(project_root)?;
407 let cache_root = build_cache_root().await?;
408 remove_project_build_cache_in(&project_root, &cache_root).await
409}
410
411async fn resolved_build_cache_root_in(water_home: &Path) -> eyre::Result<(WaterConfig, PathBuf)> {
412 let config = ensure_global_config_in(water_home).await?;
413 let cache_root = water_home.join(BUILD_CACHE_DIR_NAME);
414 fs::create_dir_all(&cache_root)
415 .await
416 .wrap_err_with(|| format!("Failed to create build cache root {}", cache_root.display()))?;
417 Ok((config, cache_root))
418}
419
420async fn ensure_global_config_in(water_home: &Path) -> eyre::Result<WaterConfig> {
421 fs::create_dir_all(water_home)
422 .await
423 .wrap_err_with(|| format!("Failed to create Water home {}", water_home.display()))?;
424
425 let config_path = water_home.join(CONFIG_FILE_NAME);
426 if config_path.exists() {
427 let contents = fs::read_to_string(&config_path)
428 .await
429 .wrap_err_with(|| format!("Failed to read Water config {}", config_path.display()))?;
430 return toml::from_str(&contents)
431 .wrap_err_with(|| format!("Failed to parse Water config {}", config_path.display()));
432 }
433
434 let config = WaterConfig::default();
435 write_global_config_in(water_home, &config).await?;
436 Ok(config)
437}
438
439pub async fn write_global_config(config: &WaterConfig) -> eyre::Result<()> {
444 let water_home = water_home_dir()?;
445 write_global_config_in(&water_home, config).await
446}
447
448async fn write_global_config_in(water_home: &Path, config: &WaterConfig) -> eyre::Result<()> {
449 let config_path = water_home.join(CONFIG_FILE_NAME);
450 let contents = toml::to_string_pretty(config).wrap_err("Failed to serialize Water config")?;
451 fs::write(&config_path, contents)
452 .await
453 .wrap_err_with(|| format!("Failed to write Water config {}", config_path.display()))
454}
455
456async fn ensure_project_build_cache_in(
457 project_root: &Path,
458 cache_root: &Path,
459 _config: &WaterConfig,
460) -> eyre::Result<PathBuf> {
461 remove_legacy_local_water_dir(project_root).await?;
462
463 let cache_dir = project_build_cache_dir_in(project_root, cache_root);
464 if cache_dir.exists() {
465 let should_clean = match read_metadata(&cache_dir).await {
466 Ok(metadata) => {
467 metadata.project_root != project_root.display().to_string()
468 || metadata.cli_commit != CLI_COMMIT
469 }
470 Err(_) => true,
471 };
472
473 if should_clean {
474 info!(
475 "Managed build cache changed shape, cleaning {}",
476 cache_dir.display()
477 );
478 fs::remove_dir_all(&cache_dir).await?;
479 prune_empty_build_cache_ancestors(
480 cache_root,
481 cache_dir
482 .parent()
483 .expect("managed build cache dir should always have a parent"),
484 )
485 .await?;
486 }
487 }
488
489 fs::create_dir_all(&cache_dir)
490 .await
491 .wrap_err_with(|| format!("Failed to create build cache dir {}", cache_dir.display()))?;
492 write_metadata(
493 &cache_dir,
494 &CacheMetadata {
495 project_root: project_root.display().to_string(),
496 cli_commit: CLI_COMMIT.to_string(),
497 last_used_unix_seconds: now_unix_seconds()?,
498 },
499 )
500 .await?;
501
502 Ok(cache_dir)
503}
504
505async fn remove_project_build_cache_in(project_root: &Path, cache_root: &Path) -> eyre::Result<()> {
506 let cache_dir = project_build_cache_dir_in(project_root, cache_root);
507 if cache_dir.exists() {
508 fs::remove_dir_all(&cache_dir).await?;
509 prune_empty_build_cache_ancestors(
510 cache_root,
511 cache_dir
512 .parent()
513 .expect("managed build cache dir should always have a parent"),
514 )
515 .await?;
516 }
517 remove_legacy_local_water_dir(project_root).await
518}
519
520fn project_build_cache_dir_in(project_root: &Path, cache_root: &Path) -> PathBuf {
521 project_cache_container_in(project_root, cache_root).join(MANAGED_BACKENDS_DIR_NAME)
522}
523
524fn project_cache_container_in(project_root: &Path, cache_root: &Path) -> PathBuf {
525 let mut path = cache_root.to_path_buf();
526 for component in project_root.components() {
527 match component {
528 Component::Prefix(prefix) => path.push(normalize_prefix_component(prefix)),
529 Component::RootDir => {}
530 Component::Normal(segment) => path.push(segment),
531 Component::CurDir | Component::ParentDir => {
532 panic!(
533 "Canonical project root {} must not contain relative path components",
534 project_root.display()
535 );
536 }
537 }
538 }
539 path
540}
541
542fn normalize_prefix_component(prefix: PrefixComponent<'_>) -> String {
543 match prefix.kind() {
544 Prefix::Disk(letter) | Prefix::VerbatimDisk(letter) => {
545 format!("drive-{}", char::from(letter).to_ascii_uppercase())
546 }
547 Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {
548 format!("unc-{}-{}", sanitize_os_str(server), sanitize_os_str(share))
549 }
550 Prefix::DeviceNS(device) => format!("device-{}", sanitize_os_str(device)),
551 Prefix::Verbatim(component) => format!("verbatim-{}", sanitize_os_str(component)),
552 }
553}
554
555fn sanitize_os_str(value: &OsStr) -> String {
556 let sanitized = value
557 .to_string_lossy()
558 .chars()
559 .map(|character| {
560 if character.is_ascii_alphanumeric() {
561 character
562 } else {
563 '_'
564 }
565 })
566 .collect::<String>();
567 if sanitized.is_empty() {
568 return String::from("empty");
569 }
570 sanitized
571}
572
573fn canonicalize_project_root(project_root: &Path) -> eyre::Result<PathBuf> {
574 project_root.canonicalize().wrap_err_with(|| {
575 format!(
576 "Failed to canonicalize project root {}",
577 project_root.display()
578 )
579 })
580}
581
582fn metadata_path(cache_dir: &Path) -> PathBuf {
583 cache_dir.join(METADATA_FILE_NAME)
584}
585
586async fn read_metadata(cache_dir: &Path) -> eyre::Result<CacheMetadata> {
587 let metadata_path = metadata_path(cache_dir);
588 let contents = fs::read_to_string(&metadata_path)
589 .await
590 .wrap_err_with(|| format!("Failed to read cache metadata {}", metadata_path.display()))?;
591 toml::from_str(&contents)
592 .wrap_err_with(|| format!("Failed to parse cache metadata {}", metadata_path.display()))
593}
594
595async fn write_metadata(cache_dir: &Path, metadata: &CacheMetadata) -> eyre::Result<()> {
596 let metadata_path = metadata_path(cache_dir);
597 let contents = toml::to_string(metadata).wrap_err("Failed to serialize cache metadata")?;
598 fs::write(&metadata_path, contents)
599 .await
600 .wrap_err_with(|| format!("Failed to write cache metadata {}", metadata_path.display()))
601}
602
603async fn remove_legacy_local_water_dir(project_root: &Path) -> eyre::Result<()> {
604 let legacy_dir = project_root.join(LEGACY_LOCAL_WATER_DIR_NAME);
605 if legacy_dir.exists() {
606 info!(
607 "Removing legacy local playground cache at {}",
608 legacy_dir.display()
609 );
610 fs::remove_dir_all(&legacy_dir).await?;
611 }
612 Ok(())
613}
614
615#[derive(Debug, Clone, PartialEq, Eq)]
617pub struct BuildCacheEntryUsage {
618 pub project_root: PathBuf,
620 pub cache_dir: PathBuf,
622 pub bytes: u64,
624 pub stale: bool,
626 pub active: bool,
628}
629
630#[derive(Debug, Clone, PartialEq, Eq)]
632pub struct BuildCacheUsageReport {
633 pub entries: Vec<BuildCacheEntryUsage>,
635 pub total_bytes: u64,
637 pub reclaimable_bytes: u64,
639}
640
641pub async fn survey_build_cache_usage(
650 current_project_root: &Path,
651) -> eyre::Result<BuildCacheUsageReport> {
652 let water_home = water_home_dir()?;
653 let (config, cache_root) = resolved_build_cache_root_in(&water_home).await?;
654 let current_cache_dir = project_build_cache_dir_in(current_project_root, &cache_root);
655 let max_unused_seconds = config
656 .build_cache
657 .cleanup_after_unused_days
658 .saturating_mul(24 * 60 * 60);
659 let now = now_unix_seconds()?;
660
661 let mut entries = Vec::new();
662 for cache_dir in discover_managed_build_cache_dirs(&cache_root).await? {
663 let metadata = read_metadata(&cache_dir).await.ok();
664 let project_root = metadata.as_ref().map_or_else(
665 || cache_dir.clone(),
666 |metadata| PathBuf::from(&metadata.project_root),
667 );
668 let active = cache_dir == current_cache_dir;
669 let stale = !active
670 && metadata.as_ref().is_none_or(|metadata| {
671 !PathBuf::from(&metadata.project_root).exists()
672 || now.saturating_sub(metadata.last_used_unix_seconds) > max_unused_seconds
673 });
674 let bytes = directory_disk_usage(cache_dir.clone()).await?;
675 entries.push(BuildCacheEntryUsage {
676 project_root,
677 cache_dir,
678 bytes,
679 stale,
680 active,
681 });
682 }
683
684 entries.sort_by(|left, right| {
685 right
686 .bytes
687 .cmp(&left.bytes)
688 .then_with(|| left.cache_dir.cmp(&right.cache_dir))
689 });
690 let total_bytes = entries.iter().map(|entry| entry.bytes).sum();
691 let reclaimable_bytes = entries
692 .iter()
693 .filter(|entry| entry.stale)
694 .map(|entry| entry.bytes)
695 .sum();
696
697 Ok(BuildCacheUsageReport {
698 entries,
699 total_bytes,
700 reclaimable_bytes,
701 })
702}
703
704async fn directory_disk_usage(root: PathBuf) -> eyre::Result<u64> {
706 smol::unblock(move || {
707 let mut seen_inodes = std::collections::HashSet::new();
708 let mut total = 0u64;
709 for entry in WalkDir::new(&root).follow_links(false) {
710 let Ok(entry) = entry else { continue };
711 if !entry.file_type().is_file() {
712 continue;
713 }
714 let Ok(metadata) = entry.metadata() else {
715 continue;
716 };
717 total = total.saturating_add(file_disk_usage(&metadata, &mut seen_inodes));
718 }
719 Ok(total)
720 })
721 .await
722}
723
724#[cfg(unix)]
725fn file_disk_usage(
726 metadata: &std::fs::Metadata,
727 seen_inodes: &mut std::collections::HashSet<(u64, u64)>,
728) -> u64 {
729 use std::os::unix::fs::MetadataExt as _;
730
731 if !seen_inodes.insert((metadata.dev(), metadata.ino())) {
732 return 0;
733 }
734 metadata.blocks().saturating_mul(512)
737}
738
739#[cfg(not(unix))]
740fn file_disk_usage(
741 metadata: &std::fs::Metadata,
742 _seen_inodes: &mut std::collections::HashSet<(u64, u64)>,
743) -> u64 {
744 metadata.len()
745}
746
747async fn cleanup_stale_caches(
748 cache_root: &Path,
749 current_project_root: &Path,
750 config: &WaterConfig,
751) -> eyre::Result<BuildCacheGcSummary> {
752 fs::create_dir_all(cache_root).await?;
753
754 let current_cache_dir = project_build_cache_dir_in(current_project_root, cache_root);
755 let max_unused_seconds = config
756 .build_cache
757 .cleanup_after_unused_days
758 .saturating_mul(24 * 60 * 60);
759 let now = now_unix_seconds()?;
760
761 let mut scanned_entries = 0usize;
762 let mut removed_entries = 0usize;
763
764 for cache_dir in discover_managed_build_cache_dirs(cache_root).await? {
765 if cache_dir == current_cache_dir {
766 continue;
767 }
768
769 scanned_entries += 1;
770
771 let should_remove = match read_metadata(&cache_dir).await {
772 Ok(metadata) => {
773 let project_root = PathBuf::from(&metadata.project_root);
774 !project_root.exists()
775 || now.saturating_sub(metadata.last_used_unix_seconds) > max_unused_seconds
776 }
777 Err(error) => {
778 warn!(
779 "Removing stale build cache with invalid metadata at {}: {error}",
780 cache_dir.display()
781 );
782 true
783 }
784 };
785
786 if should_remove {
787 if let Err(error) = fs::remove_dir_all(&cache_dir).await {
788 warn!(
789 "Failed to remove stale build cache {}: {error}",
790 cache_dir.display()
791 );
792 continue;
793 }
794 prune_empty_build_cache_ancestors(
795 cache_root,
796 cache_dir
797 .parent()
798 .expect("managed build cache dir should always have a parent"),
799 )
800 .await?;
801 removed_entries += 1;
802 }
803 }
804
805 Ok(BuildCacheGcSummary {
806 scanned_entries,
807 removed_entries,
808 })
809}
810
811async fn cleanup_stale_caches_if_idle(
812 cache_root: &Path,
813 current_project_root: &Path,
814 config: &WaterConfig,
815) -> eyre::Result<BuildCacheGcOutcome> {
816 let lock_path = cache_root.join(CLEANUP_LOCK_FILE_NAME);
817 let Some(lock_file) = try_acquire_cleanup_lock(&lock_path).await? else {
818 return Ok(BuildCacheGcOutcome::SkippedAlreadyRunning);
819 };
820
821 let cleanup_result = cleanup_stale_caches(cache_root, current_project_root, config).await;
822 drop(lock_file);
826
827 cleanup_result.map(BuildCacheGcOutcome::Ran)
828}
829
830async fn try_acquire_cleanup_lock(lock_path: &Path) -> eyre::Result<Option<std::fs::File>> {
845 let lock_path = lock_path.to_path_buf();
846 smol::unblock(move || {
847 let file = std::fs::OpenOptions::new()
848 .write(true)
849 .create(true)
850 .truncate(false)
851 .open(&lock_path)
852 .wrap_err_with(|| format!("Failed to open cleanup lock {}", lock_path.display()))?;
853 match FileExt::try_lock(&file) {
854 Ok(()) => Ok(Some(file)),
855 Err(TryLockError::WouldBlock) => Ok(None),
856 Err(TryLockError::Error(error)) => Err(eyre::Report::from(error))
857 .wrap_err_with(|| format!("Failed to lock {}", lock_path.display())),
858 }
859 })
860 .await
861}
862
863async fn discover_managed_build_cache_dirs(cache_root: &Path) -> eyre::Result<Vec<PathBuf>> {
872 let cache_root = cache_root.to_path_buf();
873 smol::unblock(move || -> eyre::Result<Vec<PathBuf>> {
874 let shared_target_dir = cache_root.join(SHARED_TARGET_DIR_NAME);
875 let mut cache_dirs = Vec::new();
876 if shared_target_dir.join(METADATA_FILE_NAME).is_file() {
877 cache_dirs.push(shared_target_dir.clone());
878 }
879 for entry in WalkDir::new(&cache_root)
880 .follow_links(false)
881 .into_iter()
882 .filter_entry(|entry| {
883 if entry.depth() == 1 && entry.path() == shared_target_dir {
886 return false;
887 }
888 if entry.file_type().is_dir()
889 && entry.file_name() == OsStr::new(MANAGED_BACKENDS_DIR_NAME)
890 {
891 if entry.path().join(METADATA_FILE_NAME).is_file() {
892 cache_dirs.push(entry.path().to_path_buf());
893 }
894 return false;
895 }
896 true
897 })
898 {
899 entry.map_err(eyre::Report::from)?;
900 }
901 Ok(cache_dirs)
902 })
903 .await
904}
905
906async fn prune_empty_build_cache_ancestors(
907 cache_root: &Path,
908 starting_dir: &Path,
909) -> eyre::Result<()> {
910 let mut current = starting_dir.to_path_buf();
911 while current.starts_with(cache_root) && current != cache_root {
912 match fs::remove_dir(¤t).await {
913 Ok(()) => {
914 let Some(parent) = current.parent() else {
915 break;
916 };
917 current = parent.to_path_buf();
918 }
919 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
920 let Some(parent) = current.parent() else {
921 break;
922 };
923 current = parent.to_path_buf();
924 }
925 Err(error) if error.kind() == std::io::ErrorKind::DirectoryNotEmpty => break,
926 Err(error) => return Err(error.into()),
927 }
928 }
929 Ok(())
930}
931
932fn now_unix_seconds() -> eyre::Result<u64> {
933 Ok(SystemTime::now()
934 .duration_since(UNIX_EPOCH)
935 .wrap_err("System clock is before UNIX_EPOCH")?
936 .as_secs())
937}
938
939#[cfg(test)]
940mod tests {
941 use std::path::{Path, PathBuf};
942
943 use tempfile::tempdir;
944
945 use super::{
946 CLI_COMMIT, WaterConfig, ensure_global_config_in, ensure_project_build_cache_in,
947 metadata_path, now_unix_seconds, project_build_cache_dir_in, remove_project_build_cache_in,
948 write_global_config_in,
949 };
950
951 #[test]
952 fn global_config_round_trips_last_used_devices() {
953 smol::block_on(async {
954 let water_home = tempdir().expect("water home");
955
956 let mut config = WaterConfig::default();
957 config.last_used_device.insert(
958 "apple/ios".to_owned(),
959 "00008140-00011C210CF3001C".to_owned(),
960 );
961 config
962 .last_used_device
963 .insert("android/android".to_owned(), "emulator-5554".to_owned());
964 write_global_config_in(water_home.path(), &config)
965 .await
966 .expect("write config");
967
968 let loaded = ensure_global_config_in(water_home.path())
969 .await
970 .expect("read config back");
971 assert_eq!(
972 loaded.last_used_device.get("apple/ios").map(String::as_str),
973 Some("00008140-00011C210CF3001C")
974 );
975 assert_eq!(
976 loaded
977 .last_used_device
978 .get("android/android")
979 .map(String::as_str),
980 Some("emulator-5554")
981 );
982 });
983 }
984
985 #[test]
986 fn ensure_global_config_writes_default_build_cache_policy() {
987 smol::block_on(async {
988 let water_home = tempdir().expect("water home");
989
990 let config = ensure_global_config_in(water_home.path())
991 .await
992 .expect("ensure config");
993
994 assert_eq!(
995 config.build_cache.cleanup_after_unused_days,
996 super::DEFAULT_BUILD_CACHE_CLEANUP_AFTER_UNUSED_DAYS
997 );
998 let saved = smol::fs::read_to_string(water_home.path().join("config.toml"))
999 .await
1000 .expect("read config");
1001 assert!(saved.contains("[build_cache]"));
1002 assert!(saved.contains("cleanup_after_unused_days = 30"));
1003 });
1004 }
1005
1006 #[test]
1007 fn project_build_cache_dir_uses_absolute_project_path_components() {
1008 let cache_root = Path::new("/tmp/water-cache-root");
1009 let project_root = if cfg!(windows) {
1010 PathBuf::from(r"C:\Users\lexo\demo")
1011 } else {
1012 PathBuf::from("/Users/lexo/demo")
1013 };
1014
1015 let cache_dir = project_build_cache_dir_in(&project_root, cache_root);
1016
1017 let expected = if cfg!(windows) {
1018 cache_root.join("drive-C/Users/lexo/demo/managed_backends")
1019 } else {
1020 cache_root.join("Users/lexo/demo/managed_backends")
1021 };
1022 assert_eq!(cache_dir, expected);
1023 }
1024
1025 #[test]
1026 fn ensure_project_build_cache_uses_global_build_cache_dir() {
1027 smol::block_on(async {
1028 let project = tempdir().expect("project dir");
1029 let water_home = tempdir().expect("water home");
1030 let config = ensure_global_config_in(water_home.path())
1031 .await
1032 .expect("ensure config");
1033 let cache_root = water_home.path().join("build_cache");
1034
1035 let cache_dir = ensure_project_build_cache_in(project.path(), &cache_root, &config)
1036 .await
1037 .expect("ensure cache");
1038
1039 assert!(cache_dir.starts_with(&cache_root));
1040 assert_ne!(cache_dir, project.path().join(".water"));
1041 assert!(cache_dir.ends_with("managed_backends"));
1042 assert!(cache_dir.exists());
1043 });
1044 }
1045
1046 #[test]
1047 fn ensure_project_build_cache_removes_legacy_local_water_dir() {
1048 smol::block_on(async {
1049 let project = tempdir().expect("project dir");
1050 let water_home = tempdir().expect("water home");
1051 let config = ensure_global_config_in(water_home.path())
1052 .await
1053 .expect("ensure config");
1054 let cache_root = water_home.path().join("build_cache");
1055 let legacy_dir = project.path().join(".water");
1056 smol::fs::create_dir_all(&legacy_dir)
1057 .await
1058 .expect("create legacy dir");
1059 smol::fs::write(legacy_dir.join("stale"), b"stale")
1060 .await
1061 .expect("write legacy file");
1062
1063 ensure_project_build_cache_in(project.path(), &cache_root, &config)
1064 .await
1065 .expect("ensure cache");
1066
1067 assert!(!legacy_dir.exists());
1068 });
1069 }
1070
1071 #[test]
1072 fn ensure_project_build_cache_cleans_cache_when_cli_commit_changes() {
1073 smol::block_on(async {
1074 let project = tempdir().expect("project dir");
1075 let water_home = tempdir().expect("water home");
1076 let config = ensure_global_config_in(water_home.path())
1077 .await
1078 .expect("ensure config");
1079 let cache_root = water_home.path().join("build_cache");
1080 let cache_dir = project_build_cache_dir_in(project.path(), &cache_root);
1081 smol::fs::create_dir_all(&cache_dir)
1082 .await
1083 .expect("create cache dir");
1084 smol::fs::write(cache_dir.join("stale"), b"stale")
1085 .await
1086 .expect("write stale file");
1087 let stale_metadata = super::CacheMetadata {
1088 project_root: project.path().display().to_string(),
1089 cli_commit: String::from("old-commit"),
1090 last_used_unix_seconds: 1,
1091 };
1092 let stale_contents =
1093 toml::to_string(&stale_metadata).expect("serialize stale metadata");
1094 smol::fs::write(metadata_path(&cache_dir), stale_contents)
1095 .await
1096 .expect("write stale metadata");
1097
1098 ensure_project_build_cache_in(project.path(), &cache_root, &config)
1099 .await
1100 .expect("ensure cache");
1101
1102 assert!(!cache_dir.join("stale").exists());
1103 let fresh_metadata = super::read_metadata(&cache_dir)
1104 .await
1105 .expect("read metadata");
1106 assert_eq!(fresh_metadata.cli_commit, CLI_COMMIT);
1107 });
1108 }
1109
1110 #[test]
1111 fn cleanup_stale_caches_removes_stale_orphaned_caches() {
1112 smol::block_on(async {
1113 let project = tempdir().expect("project dir");
1114 let water_home = tempdir().expect("water home");
1115 let config = WaterConfig::default();
1116 let cache_root = water_home.path().join("build_cache");
1117 let stale_cache = cache_root.join("definitely/missing/project/managed_backends");
1118 smol::fs::create_dir_all(&stale_cache)
1119 .await
1120 .expect("create stale cache");
1121 let stale_metadata = super::CacheMetadata {
1122 project_root: Path::new("/definitely/missing/project")
1123 .display()
1124 .to_string(),
1125 cli_commit: CLI_COMMIT.to_string(),
1126 last_used_unix_seconds: now_unix_seconds().expect("now"),
1127 };
1128 let stale_contents =
1129 toml::to_string(&stale_metadata).expect("serialize stale metadata");
1130 smol::fs::write(metadata_path(&stale_cache), stale_contents)
1131 .await
1132 .expect("write stale metadata");
1133
1134 let outcome = super::cleanup_stale_caches_if_idle(&cache_root, project.path(), &config)
1135 .await
1136 .expect("cleanup caches");
1137
1138 assert_eq!(
1139 outcome,
1140 super::BuildCacheGcOutcome::Ran(super::BuildCacheGcSummary {
1141 scanned_entries: 1,
1142 removed_entries: 1,
1143 })
1144 );
1145 assert!(!stale_cache.exists());
1146 });
1147 }
1148
1149 #[test]
1150 fn remove_project_build_cache_deletes_only_managed_backends_leaf() {
1151 smol::block_on(async {
1152 let project = tempdir().expect("project dir");
1153 let child_project = project.path().join("nested/child");
1154 smol::fs::create_dir_all(&child_project)
1155 .await
1156 .expect("create child project");
1157 let water_home = tempdir().expect("water home");
1158 let config = ensure_global_config_in(water_home.path())
1159 .await
1160 .expect("ensure config");
1161 let cache_root = water_home.path().join("build_cache");
1162
1163 let parent_cache = ensure_project_build_cache_in(project.path(), &cache_root, &config)
1164 .await
1165 .expect("ensure parent cache");
1166 let child_cache = ensure_project_build_cache_in(&child_project, &cache_root, &config)
1167 .await
1168 .expect("ensure child cache");
1169
1170 remove_project_build_cache_in(project.path(), &cache_root)
1171 .await
1172 .expect("remove parent cache");
1173
1174 assert!(!parent_cache.exists());
1175 assert!(child_cache.exists());
1176 });
1177 }
1178
1179 #[test]
1184 fn cleanup_runs_again_after_a_sweep_dies_holding_the_lock() {
1185 smol::block_on(async {
1186 let project = tempdir().expect("project dir");
1187 let water_home = tempdir().expect("water home");
1188 let config = ensure_global_config_in(water_home.path())
1189 .await
1190 .expect("ensure config");
1191 let cache_root = water_home.path().join("build_cache");
1192 smol::fs::create_dir_all(&cache_root)
1193 .await
1194 .expect("create cache root");
1195
1196 let lock_path = cache_root.join(super::CLEANUP_LOCK_FILE_NAME);
1199 smol::fs::write(&lock_path, [])
1200 .await
1201 .expect("leave a lock file behind");
1202
1203 let stale_project = tempdir().expect("stale project");
1204 let stale_cache =
1205 ensure_project_build_cache_in(stale_project.path(), &cache_root, &config)
1206 .await
1207 .expect("ensure stale cache");
1208 drop(stale_project);
1209
1210 let outcome = super::cleanup_stale_caches_if_idle(&cache_root, project.path(), &config)
1211 .await
1212 .expect("cleanup");
1213
1214 assert!(
1215 matches!(outcome, super::BuildCacheGcOutcome::Ran(_)),
1216 "an abandoned lock file must not be read as a running sweep, got {outcome:?}"
1217 );
1218 assert!(!stale_cache.exists());
1219 });
1220 }
1221
1222 #[test]
1225 fn a_second_sweep_stands_down_while_the_first_holds_the_lock() {
1226 smol::block_on(async {
1227 let project = tempdir().expect("project dir");
1228 let water_home = tempdir().expect("water home");
1229 let config = ensure_global_config_in(water_home.path())
1230 .await
1231 .expect("ensure config");
1232 let cache_root = water_home.path().join("build_cache");
1233 smol::fs::create_dir_all(&cache_root)
1234 .await
1235 .expect("create cache root");
1236
1237 let lock_path = cache_root.join(super::CLEANUP_LOCK_FILE_NAME);
1238 let held = super::try_acquire_cleanup_lock(&lock_path)
1239 .await
1240 .expect("acquire lock")
1241 .expect("lock is free");
1242
1243 let outcome = super::cleanup_stale_caches_if_idle(&cache_root, project.path(), &config)
1244 .await
1245 .expect("cleanup");
1246 assert_eq!(outcome, super::BuildCacheGcOutcome::SkippedAlreadyRunning);
1247
1248 drop(held);
1249 });
1250 }
1251
1252 #[test]
1256 fn discovery_never_walks_inside_the_shared_target() {
1257 smol::block_on(async {
1258 let cache_root = tempdir().expect("cache root");
1259 let target_dir = super::ensure_shared_target_dir_in(cache_root.path())
1260 .await
1261 .expect("ensure shared target dir");
1262 let buried = target_dir.join("debug/managed_backends");
1265 smol::fs::create_dir_all(&buried)
1266 .await
1267 .expect("create buried dir");
1268 smol::fs::write(
1269 buried.join(super::METADATA_FILE_NAME),
1270 "project_root = \"x\"",
1271 )
1272 .await
1273 .expect("write buried marker");
1274
1275 let project = tempdir().expect("project dir");
1276 let config = WaterConfig::default();
1277 let managed =
1278 super::ensure_project_build_cache_in(project.path(), cache_root.path(), &config)
1279 .await
1280 .expect("ensure managed cache");
1281
1282 let mut discovered = super::discover_managed_build_cache_dirs(cache_root.path())
1283 .await
1284 .expect("discover cache dirs");
1285 let mut expected = vec![managed, target_dir];
1286 discovered.sort();
1287 expected.sort();
1288 assert_eq!(discovered, expected);
1289 });
1290 }
1291
1292 #[test]
1295 fn shared_target_dir_refuses_while_a_build_lock_is_held() {
1296 smol::block_on(async {
1297 let cache_root = tempdir().expect("cache root");
1298 let target_dir = super::ensure_shared_target_dir_in(cache_root.path())
1299 .await
1300 .expect("ensure shared target dir");
1301 let profile = target_dir.join("shared/aarch64-apple-darwin/debug");
1302 smol::fs::create_dir_all(&profile)
1303 .await
1304 .expect("create profile dir");
1305 let lock_file =
1306 std::fs::File::create(profile.join(".cargo-lock")).expect("create cargo lock");
1307 fs4::FileExt::lock(&lock_file).expect("hold the build lock");
1308
1309 let error = super::remove_shared_target_dir_in(cache_root.path())
1310 .await
1311 .expect_err("a held build lock must refuse the drop");
1312 assert!(
1313 error.to_string().contains("in use"),
1314 "the error says why: {error}"
1315 );
1316
1317 fs4::FileExt::unlock(&lock_file).expect("release the build lock");
1318 super::remove_shared_target_dir_in(cache_root.path())
1319 .await
1320 .expect("an unlocked target drops")
1321 .expect("the target existed");
1322 });
1323 }
1324
1325 #[test]
1328 fn shared_target_dir_can_be_dropped_on_demand() {
1329 smol::block_on(async {
1330 let cache_root = tempdir().expect("cache root");
1331 let target_dir = super::ensure_shared_target_dir_in(cache_root.path())
1332 .await
1333 .expect("ensure shared target dir");
1334 smol::fs::create_dir_all(target_dir.join("debug"))
1335 .await
1336 .expect("create a unit dir");
1337 smol::fs::write(target_dir.join("debug/unit.rlib"), [0u8; 1024])
1338 .await
1339 .expect("write a unit");
1340
1341 let freed = super::remove_shared_target_dir_in(cache_root.path())
1342 .await
1343 .expect("drop the shared target");
1344 assert!(
1345 freed.is_some_and(|bytes| bytes > 0),
1346 "the drop reports the space it held: {freed:?}"
1347 );
1348 assert!(!target_dir.exists());
1349 assert_eq!(
1350 super::remove_shared_target_dir_in(cache_root.path())
1351 .await
1352 .expect("a second drop"),
1353 None,
1354 "dropping an absent shared target is a no-op"
1355 );
1356 });
1357 }
1358
1359 #[test]
1360 fn shared_target_dir_is_discovered_and_kept_while_in_use() {
1361 smol::block_on(async {
1362 let project = tempdir().expect("project dir");
1363 let config = WaterConfig::default();
1364 let cache_root = tempdir().expect("cache root");
1365
1366 let target_dir = super::ensure_shared_target_dir_in(cache_root.path())
1367 .await
1368 .expect("ensure shared target dir");
1369
1370 assert_eq!(target_dir, cache_root.path().join("target"));
1371
1372 let discovered = super::discover_managed_build_cache_dirs(cache_root.path())
1373 .await
1374 .expect("discover cache dirs");
1375 assert_eq!(discovered, vec![target_dir.clone()]);
1376
1377 let outcome =
1378 super::cleanup_stale_caches_if_idle(cache_root.path(), project.path(), &config)
1379 .await
1380 .expect("cleanup caches");
1381 assert_eq!(
1382 outcome,
1383 super::BuildCacheGcOutcome::Ran(super::BuildCacheGcSummary {
1384 scanned_entries: 1,
1385 removed_entries: 0,
1386 })
1387 );
1388 assert!(target_dir.exists());
1389 });
1390 }
1391
1392 #[test]
1393 fn stale_shared_target_dir_is_collected() {
1394 smol::block_on(async {
1395 let project = tempdir().expect("project dir");
1396 let config = WaterConfig::default();
1397 let cache_root = tempdir().expect("cache root");
1398
1399 let target_dir = super::ensure_shared_target_dir_in(cache_root.path())
1400 .await
1401 .expect("ensure shared target dir");
1402 let stale_metadata = super::CacheMetadata {
1403 project_root: target_dir.display().to_string(),
1404 cli_commit: CLI_COMMIT.to_string(),
1405 last_used_unix_seconds: 1,
1406 };
1407 smol::fs::write(
1408 metadata_path(&target_dir),
1409 toml::to_string(&stale_metadata).expect("serialize stale metadata"),
1410 )
1411 .await
1412 .expect("write stale metadata");
1413
1414 let outcome =
1415 super::cleanup_stale_caches_if_idle(cache_root.path(), project.path(), &config)
1416 .await
1417 .expect("cleanup caches");
1418 assert_eq!(
1419 outcome,
1420 super::BuildCacheGcOutcome::Ran(super::BuildCacheGcSummary {
1421 scanned_entries: 1,
1422 removed_entries: 1,
1423 })
1424 );
1425 assert!(!target_dir.exists());
1426 });
1427 }
1428}