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