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
186pub async fn shared_target_dir_path() -> eyre::Result<PathBuf> {
192 let water_home = water_home_dir()?;
193 let (_, cache_root) = resolved_build_cache_root_in(&water_home).await?;
194 Ok(cache_root.join(SHARED_TARGET_DIR_NAME))
195}
196
197async fn ensure_shared_target_dir_in(cache_root: &Path) -> eyre::Result<PathBuf> {
198 let target_dir = cache_root.join(SHARED_TARGET_DIR_NAME);
199 fs::create_dir_all(&target_dir).await.wrap_err_with(|| {
200 format!(
201 "Failed to create shared target dir {}",
202 target_dir.display()
203 )
204 })?;
205 write_metadata(
208 &target_dir,
209 &CacheMetadata {
210 project_root: target_dir.display().to_string(),
211 cli_commit: CLI_COMMIT.to_string(),
212 last_used_unix_seconds: now_unix_seconds()?,
213 },
214 )
215 .await?;
216 Ok(target_dir)
217}
218
219pub async fn remove_shared_target_dir() -> eyre::Result<Option<u64>> {
234 let water_home = water_home_dir()?;
235 let (_, cache_root) = resolved_build_cache_root_in(&water_home).await?;
236 remove_shared_target_dir_in(&cache_root).await
237}
238
239async fn remove_shared_target_dir_in(cache_root: &Path) -> eyre::Result<Option<u64>> {
240 let target_dir = cache_root.join(SHARED_TARGET_DIR_NAME);
241 if !target_dir.exists() {
242 return Ok(None);
243 }
244 shared_target_in_use(&target_dir).await?;
245 let bytes = directory_disk_usage(target_dir.clone()).await?;
246 fs::remove_dir_all(&target_dir).await.wrap_err_with(|| {
247 format!(
248 "Failed to remove shared target dir {}",
249 target_dir.display()
250 )
251 })?;
252 Ok(Some(bytes))
253}
254
255pub async fn remove_project_units_from_shared_target(
278 packages: &[String],
279) -> eyre::Result<Vec<PathBuf>> {
280 remove_project_units_in(&shared_target_dir_path().await?, packages).await
281}
282
283async fn remove_project_units_in(
284 target_dir: &Path,
285 packages: &[String],
286) -> eyre::Result<Vec<PathBuf>> {
287 if !target_dir.exists() || packages.is_empty() {
288 return Ok(Vec::new());
289 }
290 shared_target_in_use(target_dir).await?;
291 let target_dir = target_dir.to_path_buf();
292 let packages = packages.to_vec();
293 smol::unblock(move || -> eyre::Result<Vec<PathBuf>> {
294 let mut removed = Vec::new();
295 let mut pending = vec![(target_dir, 0usize)];
296 while let Some((dir, depth)) = pending.pop() {
297 if dir.join(".cargo-lock").is_file() {
298 removed.extend(remove_package_units_in_profile(&dir, &packages)?);
299 continue;
300 }
301 if depth == 3 {
302 continue;
303 }
304 for entry in std::fs::read_dir(&dir)
305 .wrap_err_with(|| format!("Failed to read {}", dir.display()))?
306 {
307 let entry = entry?;
308 if entry.file_type()?.is_dir() {
309 pending.push((entry.path(), depth + 1));
310 }
311 }
312 }
313 removed.sort();
314 Ok(removed)
315 })
316 .await
317}
318
319const PROFILE_UNIT_DIRS: [&str; 4] = ["deps", ".fingerprint", "build", "incremental"];
322
323fn remove_package_units_in_profile(
324 profile_dir: &Path,
325 packages: &[String],
326) -> eyre::Result<Vec<PathBuf>> {
327 let mut removed = Vec::new();
328 let mut dirs = vec![profile_dir.to_path_buf()];
329 dirs.extend(
330 PROFILE_UNIT_DIRS
331 .iter()
332 .map(|name| profile_dir.join(name))
333 .filter(|dir| dir.is_dir()),
334 );
335 for dir in dirs {
336 for entry in
337 std::fs::read_dir(&dir).wrap_err_with(|| format!("Failed to read {}", dir.display()))?
338 {
339 let entry = entry?;
340 let file_name = entry.file_name();
341 let Some(name) = file_name.to_str() else {
342 continue;
343 };
344 if !packages
345 .iter()
346 .any(|package| unit_entry_belongs_to(name, package))
347 {
348 continue;
349 }
350 let path = entry.path();
351 if entry.file_type()?.is_dir() {
352 std::fs::remove_dir_all(&path)
353 } else {
354 std::fs::remove_file(&path)
355 }
356 .wrap_err_with(|| format!("Failed to remove {}", path.display()))?;
357 removed.push(path);
358 }
359 }
360 Ok(removed)
361}
362
363fn unit_entry_belongs_to(entry_name: &str, package: &str) -> bool {
372 let target_name = package.replace('-', "_");
373 let stem = entry_name.strip_prefix("lib").unwrap_or(entry_name);
374 [entry_name, stem].into_iter().any(|candidate| {
375 [package, target_name.as_str()].into_iter().any(|name| {
376 candidate
377 .strip_prefix(name)
378 .is_some_and(|rest| rest.is_empty() || rest.starts_with(['-', '.', '_']))
379 })
380 })
381}
382
383async fn shared_target_in_use(target_dir: &Path) -> eyre::Result<()> {
392 let target_dir = target_dir.to_path_buf();
393 smol::unblock(move || -> eyre::Result<()> {
394 let mut profile_dirs = Vec::new();
395 let mut pending = vec![(target_dir.clone(), 0usize)];
396 while let Some((dir, depth)) = pending.pop() {
397 if depth == 3 {
398 continue;
399 }
400 for entry in std::fs::read_dir(&dir)? {
401 let entry = entry?;
402 if entry.file_type()?.is_dir() {
403 let path = entry.path();
404 if path.join(".cargo-lock").exists() {
405 profile_dirs.push(path.clone());
406 }
407 pending.push((path, depth + 1));
408 }
409 }
410 }
411 for dir in &profile_dirs {
412 let lock_path = dir.join(".cargo-lock");
413 let file = std::fs::OpenOptions::new()
414 .write(true)
415 .open(&lock_path)
416 .wrap_err_with(|| format!("Failed to open {}", lock_path.display()))?;
417 match FileExt::try_lock(&file) {
418 Ok(()) => {}
419 Err(TryLockError::WouldBlock) => {
420 return Err(eyre::eyre!(
421 "the shared Cargo target {} is in use by a running build \
422 ({} is locked) — drop it once the build finishes",
423 target_dir.display(),
424 lock_path.display()
425 ));
426 }
427 Err(TryLockError::Error(error)) => {
428 return Err(eyre::Report::from(error))
429 .wrap_err_with(|| format!("Failed to lock {}", lock_path.display()));
430 }
431 }
432 }
433 Ok(())
434 })
435 .await
436}
437
438pub async fn project_build_cache_dir(project_root: &Path) -> eyre::Result<PathBuf> {
443 let project_root = canonicalize_project_root(project_root)?;
444 let cache_root = build_cache_root().await?;
445 Ok(project_build_cache_dir_in(&project_root, &cache_root))
446}
447
448pub async fn build_cache_container_for(project_root: &Path) -> eyre::Result<PathBuf> {
461 let mut trailing = Vec::new();
462 let mut existing = project_root.to_path_buf();
463 let resolved = loop {
464 if let Ok(resolved) = existing.canonicalize() {
465 break resolved;
466 }
467 let name = existing.file_name().map(std::ffi::OsString::from);
468 let parent = existing.parent().map(Path::to_path_buf);
469 let (Some(name), Some(parent)) = (name, parent) else {
470 return Err(eyre::eyre!(
471 "Failed to resolve any existing ancestor of {}",
472 project_root.display()
473 ));
474 };
475 trailing.push(name);
476 existing = parent;
477 };
478 let mut project_root = resolved;
479 for name in trailing.iter().rev() {
480 project_root.push(name);
481 }
482 let cache_root = build_cache_root().await?;
483 Ok(project_cache_container_in(&project_root, &cache_root))
484}
485
486pub async fn ensure_project_build_cache(project_root: &Path) -> eyre::Result<PathBuf> {
491 let project_root = canonicalize_project_root(project_root)?;
492 let water_home = water_home_dir()?;
493 let (config, cache_root) = resolved_build_cache_root_in(&water_home).await?;
494 if let Err(error) = spawn_build_cache_cleanup_process(&project_root).await {
495 warn!(
496 current_project_root = %project_root.display(),
497 "Failed to spawn build-cache cleanup process: {error}"
498 );
499 }
500 ensure_project_build_cache_in(&project_root, &cache_root, &config).await
501}
502
503pub async fn cleanup_stale_build_caches_for_project(
509 project_root: &Path,
510) -> eyre::Result<BuildCacheGcOutcome> {
511 let project_root = canonicalize_project_root(project_root)?;
512 let water_home = water_home_dir()?;
513 let (config, cache_root) = resolved_build_cache_root_in(&water_home).await?;
514 cleanup_stale_caches_if_idle(&cache_root, &project_root, &config).await
515}
516
517async fn spawn_build_cache_cleanup_process(project_root: &Path) -> eyre::Result<()> {
518 let current_executable = std::env::current_exe()
519 .wrap_err("Failed to resolve current water executable for build-cache cleanup")?;
520 let project_root = project_root.to_path_buf();
521
522 smol::unblock(move || -> eyre::Result<()> {
523 std::process::Command::new(¤t_executable)
524 .arg("gc")
525 .arg("build-cache")
526 .arg("--path")
527 .arg(&project_root)
528 .stdin(Stdio::null())
529 .stdout(Stdio::null())
530 .stderr(Stdio::null())
531 .spawn()
532 .map(|_| ())
533 .map_err(eyre::Report::from)
534 .wrap_err_with(|| {
535 format!(
536 "Failed to spawn build-cache cleanup process for {}",
537 project_root.display()
538 )
539 })
540 })
541 .await
542}
543
544pub async fn remove_project_build_cache(project_root: &Path) -> eyre::Result<()> {
549 let project_root = canonicalize_project_root(project_root)?;
550 let cache_root = build_cache_root().await?;
551 remove_project_build_cache_in(&project_root, &cache_root).await
552}
553
554async fn resolved_build_cache_root_in(water_home: &Path) -> eyre::Result<(WaterConfig, PathBuf)> {
555 let config = ensure_global_config_in(water_home).await?;
556 let cache_root = water_home.join(BUILD_CACHE_DIR_NAME);
557 fs::create_dir_all(&cache_root)
558 .await
559 .wrap_err_with(|| format!("Failed to create build cache root {}", cache_root.display()))?;
560 Ok((config, cache_root))
561}
562
563pub(crate) async fn ensure_global_config_in(water_home: &Path) -> eyre::Result<WaterConfig> {
564 fs::create_dir_all(water_home)
565 .await
566 .wrap_err_with(|| format!("Failed to create Water home {}", water_home.display()))?;
567
568 let config_path = water_home.join(CONFIG_FILE_NAME);
569 if config_path.exists() {
570 let contents = fs::read_to_string(&config_path)
571 .await
572 .wrap_err_with(|| format!("Failed to read Water config {}", config_path.display()))?;
573 return toml::from_str(&contents)
574 .wrap_err_with(|| format!("Failed to parse Water config {}", config_path.display()));
575 }
576
577 let config = WaterConfig::default();
578 write_global_config_in(water_home, &config).await?;
579 Ok(config)
580}
581
582pub async fn write_global_config(config: &WaterConfig) -> eyre::Result<()> {
587 let water_home = water_home_dir()?;
588 write_global_config_in(&water_home, config).await
589}
590
591pub(crate) async fn write_global_config_in(
592 water_home: &Path,
593 config: &WaterConfig,
594) -> eyre::Result<()> {
595 let config_path = water_home.join(CONFIG_FILE_NAME);
596 let contents = toml::to_string_pretty(config).wrap_err("Failed to serialize Water config")?;
597 fs::write(&config_path, contents)
598 .await
599 .wrap_err_with(|| format!("Failed to write Water config {}", config_path.display()))
600}
601
602async fn ensure_project_build_cache_in(
603 project_root: &Path,
604 cache_root: &Path,
605 _config: &WaterConfig,
606) -> eyre::Result<PathBuf> {
607 remove_legacy_local_water_dir(project_root).await?;
608
609 let cache_dir = project_build_cache_dir_in(project_root, cache_root);
610 if cache_dir.exists() {
611 let should_clean = match read_metadata(&cache_dir).await {
612 Ok(metadata) => {
613 metadata.project_root != project_root.display().to_string()
614 || metadata.cli_commit != CLI_COMMIT
615 }
616 Err(_) => true,
617 };
618
619 if should_clean {
620 info!(
621 "Managed build cache changed shape, cleaning {}",
622 cache_dir.display()
623 );
624 fs::remove_dir_all(&cache_dir).await?;
625 prune_empty_build_cache_ancestors(
626 cache_root,
627 cache_dir
628 .parent()
629 .expect("managed build cache dir should always have a parent"),
630 )
631 .await?;
632 }
633 }
634
635 fs::create_dir_all(&cache_dir)
636 .await
637 .wrap_err_with(|| format!("Failed to create build cache dir {}", cache_dir.display()))?;
638 write_metadata(
639 &cache_dir,
640 &CacheMetadata {
641 project_root: project_root.display().to_string(),
642 cli_commit: CLI_COMMIT.to_string(),
643 last_used_unix_seconds: now_unix_seconds()?,
644 },
645 )
646 .await?;
647
648 Ok(cache_dir)
649}
650
651async fn remove_project_build_cache_in(project_root: &Path, cache_root: &Path) -> eyre::Result<()> {
652 let cache_dir = project_build_cache_dir_in(project_root, cache_root);
653 if cache_dir.exists() {
654 fs::remove_dir_all(&cache_dir).await?;
655 prune_empty_build_cache_ancestors(
656 cache_root,
657 cache_dir
658 .parent()
659 .expect("managed build cache dir should always have a parent"),
660 )
661 .await?;
662 }
663 remove_legacy_local_water_dir(project_root).await
664}
665
666fn project_build_cache_dir_in(project_root: &Path, cache_root: &Path) -> PathBuf {
667 project_cache_container_in(project_root, cache_root).join(MANAGED_BACKENDS_DIR_NAME)
668}
669
670fn project_cache_container_in(project_root: &Path, cache_root: &Path) -> PathBuf {
671 let mut path = cache_root.to_path_buf();
672 for component in project_root.components() {
673 match component {
674 Component::Prefix(prefix) => path.push(normalize_prefix_component(prefix)),
675 Component::RootDir => {}
676 Component::Normal(segment) => path.push(segment),
677 Component::CurDir | Component::ParentDir => {
678 panic!(
679 "Canonical project root {} must not contain relative path components",
680 project_root.display()
681 );
682 }
683 }
684 }
685 path
686}
687
688fn normalize_prefix_component(prefix: PrefixComponent<'_>) -> String {
689 match prefix.kind() {
690 Prefix::Disk(letter) | Prefix::VerbatimDisk(letter) => {
691 format!("drive-{}", char::from(letter).to_ascii_uppercase())
692 }
693 Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {
694 format!("unc-{}-{}", sanitize_os_str(server), sanitize_os_str(share))
695 }
696 Prefix::DeviceNS(device) => format!("device-{}", sanitize_os_str(device)),
697 Prefix::Verbatim(component) => format!("verbatim-{}", sanitize_os_str(component)),
698 }
699}
700
701fn sanitize_os_str(value: &OsStr) -> String {
702 let sanitized = value
703 .to_string_lossy()
704 .chars()
705 .map(|character| {
706 if character.is_ascii_alphanumeric() {
707 character
708 } else {
709 '_'
710 }
711 })
712 .collect::<String>();
713 if sanitized.is_empty() {
714 return String::from("empty");
715 }
716 sanitized
717}
718
719fn canonicalize_project_root(project_root: &Path) -> eyre::Result<PathBuf> {
720 project_root.canonicalize().wrap_err_with(|| {
721 format!(
722 "Failed to canonicalize project root {}",
723 project_root.display()
724 )
725 })
726}
727
728fn metadata_path(cache_dir: &Path) -> PathBuf {
729 cache_dir.join(METADATA_FILE_NAME)
730}
731
732async fn read_metadata(cache_dir: &Path) -> eyre::Result<CacheMetadata> {
733 let metadata_path = metadata_path(cache_dir);
734 let contents = fs::read_to_string(&metadata_path)
735 .await
736 .wrap_err_with(|| format!("Failed to read cache metadata {}", metadata_path.display()))?;
737 toml::from_str(&contents)
738 .wrap_err_with(|| format!("Failed to parse cache metadata {}", metadata_path.display()))
739}
740
741async fn write_metadata(cache_dir: &Path, metadata: &CacheMetadata) -> eyre::Result<()> {
742 let metadata_path = metadata_path(cache_dir);
743 let contents = toml::to_string(metadata).wrap_err("Failed to serialize cache metadata")?;
744 fs::write(&metadata_path, contents)
745 .await
746 .wrap_err_with(|| format!("Failed to write cache metadata {}", metadata_path.display()))
747}
748
749async fn remove_legacy_local_water_dir(project_root: &Path) -> eyre::Result<()> {
750 let legacy_dir = project_root.join(LEGACY_LOCAL_WATER_DIR_NAME);
751 if legacy_dir.exists() {
752 info!(
753 "Removing legacy local playground cache at {}",
754 legacy_dir.display()
755 );
756 fs::remove_dir_all(&legacy_dir).await?;
757 }
758 Ok(())
759}
760
761#[derive(Debug, Clone, PartialEq, Eq)]
763pub struct BuildCacheEntryUsage {
764 pub project_root: PathBuf,
766 pub cache_dir: PathBuf,
768 pub bytes: u64,
770 pub stale: bool,
772 pub active: bool,
774}
775
776#[derive(Debug, Clone, PartialEq, Eq)]
778pub struct BuildCacheUsageReport {
779 pub entries: Vec<BuildCacheEntryUsage>,
781 pub total_bytes: u64,
783 pub reclaimable_bytes: u64,
785}
786
787pub async fn survey_build_cache_usage(
796 current_project_root: &Path,
797) -> eyre::Result<BuildCacheUsageReport> {
798 let water_home = water_home_dir()?;
799 let (config, cache_root) = resolved_build_cache_root_in(&water_home).await?;
800 let current_cache_dir = project_build_cache_dir_in(current_project_root, &cache_root);
801 let max_unused_seconds = config
802 .build_cache
803 .cleanup_after_unused_days
804 .saturating_mul(24 * 60 * 60);
805 let now = now_unix_seconds()?;
806
807 let mut entries = Vec::new();
808 for cache_dir in discover_managed_build_cache_dirs(&cache_root).await? {
809 let metadata = read_metadata(&cache_dir).await.ok();
810 let project_root = metadata.as_ref().map_or_else(
811 || cache_dir.clone(),
812 |metadata| PathBuf::from(&metadata.project_root),
813 );
814 let active = cache_dir == current_cache_dir;
815 let stale = !active
816 && metadata.as_ref().is_none_or(|metadata| {
817 !PathBuf::from(&metadata.project_root).exists()
818 || now.saturating_sub(metadata.last_used_unix_seconds) > max_unused_seconds
819 });
820 let bytes = directory_disk_usage(cache_dir.clone()).await?;
821 entries.push(BuildCacheEntryUsage {
822 project_root,
823 cache_dir,
824 bytes,
825 stale,
826 active,
827 });
828 }
829
830 entries.sort_by(|left, right| {
831 right
832 .bytes
833 .cmp(&left.bytes)
834 .then_with(|| left.cache_dir.cmp(&right.cache_dir))
835 });
836 let total_bytes = entries.iter().map(|entry| entry.bytes).sum();
837 let reclaimable_bytes = entries
838 .iter()
839 .filter(|entry| entry.stale)
840 .map(|entry| entry.bytes)
841 .sum();
842
843 Ok(BuildCacheUsageReport {
844 entries,
845 total_bytes,
846 reclaimable_bytes,
847 })
848}
849
850async fn directory_disk_usage(root: PathBuf) -> eyre::Result<u64> {
852 smol::unblock(move || {
853 let mut seen_inodes = std::collections::HashSet::new();
854 let mut total = 0u64;
855 for entry in WalkDir::new(&root).follow_links(false) {
856 let Ok(entry) = entry else { continue };
857 if !entry.file_type().is_file() {
858 continue;
859 }
860 let Ok(metadata) = entry.metadata() else {
861 continue;
862 };
863 total = total.saturating_add(file_disk_usage(&metadata, &mut seen_inodes));
864 }
865 Ok(total)
866 })
867 .await
868}
869
870#[cfg(unix)]
871fn file_disk_usage(
872 metadata: &std::fs::Metadata,
873 seen_inodes: &mut std::collections::HashSet<(u64, u64)>,
874) -> u64 {
875 use std::os::unix::fs::MetadataExt as _;
876
877 if !seen_inodes.insert((metadata.dev(), metadata.ino())) {
878 return 0;
879 }
880 metadata.blocks().saturating_mul(512)
883}
884
885#[cfg(not(unix))]
886fn file_disk_usage(
887 metadata: &std::fs::Metadata,
888 _seen_inodes: &mut std::collections::HashSet<(u64, u64)>,
889) -> u64 {
890 metadata.len()
891}
892
893async fn cleanup_stale_caches(
894 cache_root: &Path,
895 current_project_root: &Path,
896 config: &WaterConfig,
897) -> eyre::Result<BuildCacheGcSummary> {
898 fs::create_dir_all(cache_root).await?;
899
900 let current_cache_dir = project_build_cache_dir_in(current_project_root, cache_root);
901 let max_unused_seconds = config
902 .build_cache
903 .cleanup_after_unused_days
904 .saturating_mul(24 * 60 * 60);
905 let now = now_unix_seconds()?;
906
907 let mut scanned_entries = 0usize;
908 let mut removed_entries = 0usize;
909
910 for cache_dir in discover_managed_build_cache_dirs(cache_root).await? {
911 if cache_dir == current_cache_dir {
912 continue;
913 }
914
915 scanned_entries += 1;
916
917 let should_remove = match read_metadata(&cache_dir).await {
918 Ok(metadata) => {
919 let project_root = PathBuf::from(&metadata.project_root);
920 !project_root.exists()
921 || now.saturating_sub(metadata.last_used_unix_seconds) > max_unused_seconds
922 }
923 Err(error) => {
924 warn!(
925 "Removing stale build cache with invalid metadata at {}: {error}",
926 cache_dir.display()
927 );
928 true
929 }
930 };
931
932 if should_remove {
933 if let Err(error) = fs::remove_dir_all(&cache_dir).await {
934 warn!(
935 "Failed to remove stale build cache {}: {error}",
936 cache_dir.display()
937 );
938 continue;
939 }
940 prune_empty_build_cache_ancestors(
941 cache_root,
942 cache_dir
943 .parent()
944 .expect("managed build cache dir should always have a parent"),
945 )
946 .await?;
947 removed_entries += 1;
948 }
949 }
950
951 Ok(BuildCacheGcSummary {
952 scanned_entries,
953 removed_entries,
954 })
955}
956
957async fn cleanup_stale_caches_if_idle(
958 cache_root: &Path,
959 current_project_root: &Path,
960 config: &WaterConfig,
961) -> eyre::Result<BuildCacheGcOutcome> {
962 let lock_path = cache_root.join(CLEANUP_LOCK_FILE_NAME);
963 let Some(lock_file) = try_acquire_cleanup_lock(&lock_path).await? else {
964 return Ok(BuildCacheGcOutcome::SkippedAlreadyRunning);
965 };
966
967 let cleanup_result = cleanup_stale_caches(cache_root, current_project_root, config).await;
968 drop(lock_file);
972
973 cleanup_result.map(BuildCacheGcOutcome::Ran)
974}
975
976async fn try_acquire_cleanup_lock(lock_path: &Path) -> eyre::Result<Option<std::fs::File>> {
991 let lock_path = lock_path.to_path_buf();
992 smol::unblock(move || {
993 let file = std::fs::OpenOptions::new()
994 .write(true)
995 .create(true)
996 .truncate(false)
997 .open(&lock_path)
998 .wrap_err_with(|| format!("Failed to open cleanup lock {}", lock_path.display()))?;
999 match FileExt::try_lock(&file) {
1000 Ok(()) => Ok(Some(file)),
1001 Err(TryLockError::WouldBlock) => Ok(None),
1002 Err(TryLockError::Error(error)) => Err(eyre::Report::from(error))
1003 .wrap_err_with(|| format!("Failed to lock {}", lock_path.display())),
1004 }
1005 })
1006 .await
1007}
1008
1009async fn discover_managed_build_cache_dirs(cache_root: &Path) -> eyre::Result<Vec<PathBuf>> {
1018 let cache_root = cache_root.to_path_buf();
1019 smol::unblock(move || -> eyre::Result<Vec<PathBuf>> {
1020 let shared_target_dir = cache_root.join(SHARED_TARGET_DIR_NAME);
1021 let mut cache_dirs = Vec::new();
1022 if shared_target_dir.join(METADATA_FILE_NAME).is_file() {
1023 cache_dirs.push(shared_target_dir.clone());
1024 }
1025 for entry in WalkDir::new(&cache_root)
1026 .follow_links(false)
1027 .into_iter()
1028 .filter_entry(|entry| {
1029 if entry.depth() == 1 && entry.path() == shared_target_dir {
1032 return false;
1033 }
1034 if entry.file_type().is_dir()
1035 && entry.file_name() == OsStr::new(MANAGED_BACKENDS_DIR_NAME)
1036 {
1037 if entry.path().join(METADATA_FILE_NAME).is_file() {
1038 cache_dirs.push(entry.path().to_path_buf());
1039 }
1040 return false;
1041 }
1042 true
1043 })
1044 {
1045 entry.map_err(eyre::Report::from)?;
1046 }
1047 Ok(cache_dirs)
1048 })
1049 .await
1050}
1051
1052async fn prune_empty_build_cache_ancestors(
1053 cache_root: &Path,
1054 starting_dir: &Path,
1055) -> eyre::Result<()> {
1056 let mut current = starting_dir.to_path_buf();
1057 while current.starts_with(cache_root) && current != cache_root {
1058 match fs::remove_dir(¤t).await {
1059 Ok(()) => {
1060 let Some(parent) = current.parent() else {
1061 break;
1062 };
1063 current = parent.to_path_buf();
1064 }
1065 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1066 let Some(parent) = current.parent() else {
1067 break;
1068 };
1069 current = parent.to_path_buf();
1070 }
1071 Err(error) if error.kind() == std::io::ErrorKind::DirectoryNotEmpty => break,
1072 Err(error) => return Err(error.into()),
1073 }
1074 }
1075 Ok(())
1076}
1077
1078fn now_unix_seconds() -> eyre::Result<u64> {
1079 Ok(SystemTime::now()
1080 .duration_since(UNIX_EPOCH)
1081 .wrap_err("System clock is before UNIX_EPOCH")?
1082 .as_secs())
1083}
1084
1085#[cfg(test)]
1086mod tests {
1087 use std::path::{Path, PathBuf};
1088
1089 use tempfile::tempdir;
1090
1091 use super::{
1092 CLI_COMMIT, WaterConfig, ensure_global_config_in, ensure_project_build_cache_in,
1093 metadata_path, now_unix_seconds, project_build_cache_dir_in, remove_project_build_cache_in,
1094 write_global_config_in,
1095 };
1096
1097 #[test]
1098 fn global_config_round_trips_last_used_devices() {
1099 smol::block_on(async {
1100 let water_home = tempdir().expect("water home");
1101
1102 let mut config = WaterConfig::default();
1103 config.last_used_device.insert(
1104 "apple/ios".to_owned(),
1105 "00008140-00011C210CF3001C".to_owned(),
1106 );
1107 config
1108 .last_used_device
1109 .insert("android/android".to_owned(), "emulator-5554".to_owned());
1110 write_global_config_in(water_home.path(), &config)
1111 .await
1112 .expect("write config");
1113
1114 let loaded = ensure_global_config_in(water_home.path())
1115 .await
1116 .expect("read config back");
1117 assert_eq!(
1118 loaded.last_used_device.get("apple/ios").map(String::as_str),
1119 Some("00008140-00011C210CF3001C")
1120 );
1121 assert_eq!(
1122 loaded
1123 .last_used_device
1124 .get("android/android")
1125 .map(String::as_str),
1126 Some("emulator-5554")
1127 );
1128 });
1129 }
1130
1131 #[test]
1132 fn ensure_global_config_writes_default_build_cache_policy() {
1133 smol::block_on(async {
1134 let water_home = tempdir().expect("water home");
1135
1136 let config = ensure_global_config_in(water_home.path())
1137 .await
1138 .expect("ensure config");
1139
1140 assert_eq!(
1141 config.build_cache.cleanup_after_unused_days,
1142 super::DEFAULT_BUILD_CACHE_CLEANUP_AFTER_UNUSED_DAYS
1143 );
1144 let saved = smol::fs::read_to_string(water_home.path().join("config.toml"))
1145 .await
1146 .expect("read config");
1147 assert!(saved.contains("[build_cache]"));
1148 assert!(saved.contains("cleanup_after_unused_days = 30"));
1149 });
1150 }
1151
1152 #[test]
1153 fn project_build_cache_dir_uses_absolute_project_path_components() {
1154 let cache_root = Path::new("/tmp/water-cache-root");
1155 let project_root = if cfg!(windows) {
1156 PathBuf::from(r"C:\Users\lexo\demo")
1157 } else {
1158 PathBuf::from("/Users/lexo/demo")
1159 };
1160
1161 let cache_dir = project_build_cache_dir_in(&project_root, cache_root);
1162
1163 let expected = if cfg!(windows) {
1164 cache_root.join("drive-C/Users/lexo/demo/managed_backends")
1165 } else {
1166 cache_root.join("Users/lexo/demo/managed_backends")
1167 };
1168 assert_eq!(cache_dir, expected);
1169 }
1170
1171 #[test]
1172 fn ensure_project_build_cache_uses_global_build_cache_dir() {
1173 smol::block_on(async {
1174 let project = tempdir().expect("project dir");
1175 let water_home = tempdir().expect("water home");
1176 let config = ensure_global_config_in(water_home.path())
1177 .await
1178 .expect("ensure config");
1179 let cache_root = water_home.path().join("build_cache");
1180
1181 let cache_dir = ensure_project_build_cache_in(project.path(), &cache_root, &config)
1182 .await
1183 .expect("ensure cache");
1184
1185 assert!(cache_dir.starts_with(&cache_root));
1186 assert_ne!(cache_dir, project.path().join(".water"));
1187 assert!(cache_dir.ends_with("managed_backends"));
1188 assert!(cache_dir.exists());
1189 });
1190 }
1191
1192 #[test]
1193 fn ensure_project_build_cache_removes_legacy_local_water_dir() {
1194 smol::block_on(async {
1195 let project = tempdir().expect("project dir");
1196 let water_home = tempdir().expect("water home");
1197 let config = ensure_global_config_in(water_home.path())
1198 .await
1199 .expect("ensure config");
1200 let cache_root = water_home.path().join("build_cache");
1201 let legacy_dir = project.path().join(".water");
1202 smol::fs::create_dir_all(&legacy_dir)
1203 .await
1204 .expect("create legacy dir");
1205 smol::fs::write(legacy_dir.join("stale"), b"stale")
1206 .await
1207 .expect("write legacy file");
1208
1209 ensure_project_build_cache_in(project.path(), &cache_root, &config)
1210 .await
1211 .expect("ensure cache");
1212
1213 assert!(!legacy_dir.exists());
1214 });
1215 }
1216
1217 #[test]
1218 fn ensure_project_build_cache_cleans_cache_when_cli_commit_changes() {
1219 smol::block_on(async {
1220 let project = tempdir().expect("project dir");
1221 let water_home = tempdir().expect("water home");
1222 let config = ensure_global_config_in(water_home.path())
1223 .await
1224 .expect("ensure config");
1225 let cache_root = water_home.path().join("build_cache");
1226 let cache_dir = project_build_cache_dir_in(project.path(), &cache_root);
1227 smol::fs::create_dir_all(&cache_dir)
1228 .await
1229 .expect("create cache dir");
1230 smol::fs::write(cache_dir.join("stale"), b"stale")
1231 .await
1232 .expect("write stale file");
1233 let stale_metadata = super::CacheMetadata {
1234 project_root: project.path().display().to_string(),
1235 cli_commit: String::from("old-commit"),
1236 last_used_unix_seconds: 1,
1237 };
1238 let stale_contents =
1239 toml::to_string(&stale_metadata).expect("serialize stale metadata");
1240 smol::fs::write(metadata_path(&cache_dir), stale_contents)
1241 .await
1242 .expect("write stale metadata");
1243
1244 ensure_project_build_cache_in(project.path(), &cache_root, &config)
1245 .await
1246 .expect("ensure cache");
1247
1248 assert!(!cache_dir.join("stale").exists());
1249 let fresh_metadata = super::read_metadata(&cache_dir)
1250 .await
1251 .expect("read metadata");
1252 assert_eq!(fresh_metadata.cli_commit, CLI_COMMIT);
1253 });
1254 }
1255
1256 #[test]
1257 fn cleanup_stale_caches_removes_stale_orphaned_caches() {
1258 smol::block_on(async {
1259 let project = tempdir().expect("project dir");
1260 let water_home = tempdir().expect("water home");
1261 let config = WaterConfig::default();
1262 let cache_root = water_home.path().join("build_cache");
1263 let stale_cache = cache_root.join("definitely/missing/project/managed_backends");
1264 smol::fs::create_dir_all(&stale_cache)
1265 .await
1266 .expect("create stale cache");
1267 let stale_metadata = super::CacheMetadata {
1268 project_root: Path::new("/definitely/missing/project")
1269 .display()
1270 .to_string(),
1271 cli_commit: CLI_COMMIT.to_string(),
1272 last_used_unix_seconds: now_unix_seconds().expect("now"),
1273 };
1274 let stale_contents =
1275 toml::to_string(&stale_metadata).expect("serialize stale metadata");
1276 smol::fs::write(metadata_path(&stale_cache), stale_contents)
1277 .await
1278 .expect("write stale metadata");
1279
1280 let outcome = super::cleanup_stale_caches_if_idle(&cache_root, project.path(), &config)
1281 .await
1282 .expect("cleanup caches");
1283
1284 assert_eq!(
1285 outcome,
1286 super::BuildCacheGcOutcome::Ran(super::BuildCacheGcSummary {
1287 scanned_entries: 1,
1288 removed_entries: 1,
1289 })
1290 );
1291 assert!(!stale_cache.exists());
1292 });
1293 }
1294
1295 #[test]
1296 fn remove_project_build_cache_deletes_only_managed_backends_leaf() {
1297 smol::block_on(async {
1298 let project = tempdir().expect("project dir");
1299 let child_project = project.path().join("nested/child");
1300 smol::fs::create_dir_all(&child_project)
1301 .await
1302 .expect("create child project");
1303 let water_home = tempdir().expect("water home");
1304 let config = ensure_global_config_in(water_home.path())
1305 .await
1306 .expect("ensure config");
1307 let cache_root = water_home.path().join("build_cache");
1308
1309 let parent_cache = ensure_project_build_cache_in(project.path(), &cache_root, &config)
1310 .await
1311 .expect("ensure parent cache");
1312 let child_cache = ensure_project_build_cache_in(&child_project, &cache_root, &config)
1313 .await
1314 .expect("ensure child cache");
1315
1316 remove_project_build_cache_in(project.path(), &cache_root)
1317 .await
1318 .expect("remove parent cache");
1319
1320 assert!(!parent_cache.exists());
1321 assert!(child_cache.exists());
1322 });
1323 }
1324
1325 #[test]
1330 fn cleanup_runs_again_after_a_sweep_dies_holding_the_lock() {
1331 smol::block_on(async {
1332 let project = tempdir().expect("project dir");
1333 let water_home = tempdir().expect("water home");
1334 let config = ensure_global_config_in(water_home.path())
1335 .await
1336 .expect("ensure config");
1337 let cache_root = water_home.path().join("build_cache");
1338 smol::fs::create_dir_all(&cache_root)
1339 .await
1340 .expect("create cache root");
1341
1342 let lock_path = cache_root.join(super::CLEANUP_LOCK_FILE_NAME);
1345 smol::fs::write(&lock_path, [])
1346 .await
1347 .expect("leave a lock file behind");
1348
1349 let stale_project = tempdir().expect("stale project");
1350 let stale_cache =
1351 ensure_project_build_cache_in(stale_project.path(), &cache_root, &config)
1352 .await
1353 .expect("ensure stale cache");
1354 drop(stale_project);
1355
1356 let outcome = super::cleanup_stale_caches_if_idle(&cache_root, project.path(), &config)
1357 .await
1358 .expect("cleanup");
1359
1360 assert!(
1361 matches!(outcome, super::BuildCacheGcOutcome::Ran(_)),
1362 "an abandoned lock file must not be read as a running sweep, got {outcome:?}"
1363 );
1364 assert!(!stale_cache.exists());
1365 });
1366 }
1367
1368 #[test]
1371 fn a_second_sweep_stands_down_while_the_first_holds_the_lock() {
1372 smol::block_on(async {
1373 let project = tempdir().expect("project dir");
1374 let water_home = tempdir().expect("water home");
1375 let config = ensure_global_config_in(water_home.path())
1376 .await
1377 .expect("ensure config");
1378 let cache_root = water_home.path().join("build_cache");
1379 smol::fs::create_dir_all(&cache_root)
1380 .await
1381 .expect("create cache root");
1382
1383 let lock_path = cache_root.join(super::CLEANUP_LOCK_FILE_NAME);
1384 let held = super::try_acquire_cleanup_lock(&lock_path)
1385 .await
1386 .expect("acquire lock")
1387 .expect("lock is free");
1388
1389 let outcome = super::cleanup_stale_caches_if_idle(&cache_root, project.path(), &config)
1390 .await
1391 .expect("cleanup");
1392 assert_eq!(outcome, super::BuildCacheGcOutcome::SkippedAlreadyRunning);
1393
1394 drop(held);
1395 });
1396 }
1397
1398 #[test]
1402 fn discovery_never_walks_inside_the_shared_target() {
1403 smol::block_on(async {
1404 let cache_root = tempdir().expect("cache root");
1405 let target_dir = super::ensure_shared_target_dir_in(cache_root.path())
1406 .await
1407 .expect("ensure shared target dir");
1408 let buried = target_dir.join("debug/managed_backends");
1411 smol::fs::create_dir_all(&buried)
1412 .await
1413 .expect("create buried dir");
1414 smol::fs::write(
1415 buried.join(super::METADATA_FILE_NAME),
1416 "project_root = \"x\"",
1417 )
1418 .await
1419 .expect("write buried marker");
1420
1421 let project = tempdir().expect("project dir");
1422 let config = WaterConfig::default();
1423 let managed =
1424 super::ensure_project_build_cache_in(project.path(), cache_root.path(), &config)
1425 .await
1426 .expect("ensure managed cache");
1427
1428 let mut discovered = super::discover_managed_build_cache_dirs(cache_root.path())
1429 .await
1430 .expect("discover cache dirs");
1431 let mut expected = vec![managed, target_dir];
1432 discovered.sort();
1433 expected.sort();
1434 assert_eq!(discovered, expected);
1435 });
1436 }
1437
1438 #[test]
1441 fn shared_target_dir_refuses_while_a_build_lock_is_held() {
1442 smol::block_on(async {
1443 let cache_root = tempdir().expect("cache root");
1444 let target_dir = super::ensure_shared_target_dir_in(cache_root.path())
1445 .await
1446 .expect("ensure shared target dir");
1447 let profile = target_dir.join("shared/aarch64-apple-darwin/debug");
1448 smol::fs::create_dir_all(&profile)
1449 .await
1450 .expect("create profile dir");
1451 let lock_file =
1452 std::fs::File::create(profile.join(".cargo-lock")).expect("create cargo lock");
1453 fs4::FileExt::lock(&lock_file).expect("hold the build lock");
1454
1455 let error = super::remove_shared_target_dir_in(cache_root.path())
1456 .await
1457 .expect_err("a held build lock must refuse the drop");
1458 assert!(
1459 error.to_string().contains("in use"),
1460 "the error says why: {error}"
1461 );
1462
1463 fs4::FileExt::unlock(&lock_file).expect("release the build lock");
1464 super::remove_shared_target_dir_in(cache_root.path())
1465 .await
1466 .expect("an unlocked target drops")
1467 .expect("the target existed");
1468 });
1469 }
1470
1471 #[test]
1477 fn project_clean_removes_only_this_projects_units_from_the_shared_target() {
1478 smol::block_on(async {
1479 let cache_root = tempdir().expect("cache root");
1480 let target_dir = super::ensure_shared_target_dir_in(cache_root.path())
1481 .await
1482 .expect("ensure shared target dir");
1483 let ours = "e2eapp-hydrolysis-1a2b3c4d";
1484 let theirs = "e2eapp-hydrolysis-9f8e7d6c";
1485 let mut kept = Vec::new();
1486 let mut gone = Vec::new();
1487 for profile in [
1488 "shared/x86_64-pc-windows-msvc/debug",
1489 "static/x86_64-pc-windows-msvc/release",
1490 "host/debug",
1491 ] {
1492 let profile = target_dir.join(profile);
1493 for dir in ["deps", ".fingerprint", "build", "incremental"] {
1494 std::fs::create_dir_all(profile.join(dir)).expect("profile dir");
1495 }
1496 std::fs::write(profile.join(".cargo-lock"), []).expect("cargo lock");
1497 let entries = [
1499 ("e2eapp_hydrolysis_1a2b3c4d.exe", false, true),
1500 ("e2eapp_hydrolysis_1a2b3c4d.pdb", false, true),
1501 ("e2eapp_hydrolysis_1a2b3c4d.d", false, true),
1502 ("e2eapp_hydrolysis_1a2b3c4d_cef_helper.exe", false, true),
1503 ("deps/e2eapp_hydrolysis_1a2b3c4d-0011.exe", false, true),
1504 ("deps/libe2eapp_hydrolysis_1a2b3c4d-0022.rlib", false, true),
1505 ("deps/e2eapp_hydrolysis_1a2b3c4d-0022.d", false, true),
1506 (".fingerprint/e2eapp-hydrolysis-1a2b3c4d-0011", true, true),
1507 ("build/e2eapp-hydrolysis-1a2b3c4d-0033", true, true),
1508 ("incremental/e2eapp_hydrolysis_1a2b3c4d-abc", true, true),
1509 ("e2eapp_hydrolysis_9f8e7d6c.exe", false, false),
1510 ("deps/libe2eapp_hydrolysis_9f8e7d6c-0044.rlib", false, false),
1511 (".fingerprint/e2eapp-hydrolysis-9f8e7d6c-0044", true, false),
1512 ("waterui_dylib.dll", false, false),
1513 ("deps/waterui_dylib-0055.dll", false, false),
1514 ("deps/libwaterui-0066.rlib", false, false),
1515 (".fingerprint/waterui-dylib-0055", true, false),
1516 ("build/waterui-chromium-0077", true, false),
1517 ];
1518 for (relative, is_dir, is_ours) in entries {
1519 let path = profile.join(relative);
1520 if is_dir {
1521 std::fs::create_dir_all(path.join("output")).expect("unit dir");
1522 } else {
1523 std::fs::write(&path, []).expect("unit file");
1524 }
1525 if is_ours { &mut gone } else { &mut kept }.push(path);
1526 }
1527 }
1528 let metadata = target_dir.join("metadata.toml");
1530 assert!(metadata.is_file(), "the shared target keeps its metadata");
1531
1532 let removed = super::remove_project_units_in(&target_dir, &[ours.to_owned()])
1533 .await
1534 .expect("clean this project's units");
1535
1536 gone.sort();
1537 assert_eq!(removed, gone, "exactly this project's units are reported");
1538 for path in &gone {
1539 assert!(!path.exists(), "{} was removed", path.display());
1540 }
1541 for path in &kept {
1542 assert!(path.exists(), "{} stays", path.display());
1543 }
1544 assert!(metadata.is_file());
1545 assert!(
1546 super::remove_project_units_in(&target_dir, &[theirs.to_owned()])
1547 .await
1548 .expect("clean the other project")
1549 .iter()
1550 .all(|path| kept.contains(path)),
1551 "the other project's clean removes only what this one kept"
1552 );
1553 assert!(
1554 super::remove_project_units_in(&target_dir, &[ours.to_owned()])
1555 .await
1556 .expect("a second clean")
1557 .is_empty(),
1558 "a second clean finds nothing"
1559 );
1560 });
1561 }
1562
1563 #[test]
1566 fn project_clean_of_the_shared_target_refuses_while_a_build_lock_is_held() {
1567 smol::block_on(async {
1568 let cache_root = tempdir().expect("cache root");
1569 let target_dir = super::ensure_shared_target_dir_in(cache_root.path())
1570 .await
1571 .expect("ensure shared target dir");
1572 let profile = target_dir.join("shared/aarch64-apple-darwin/debug");
1573 std::fs::create_dir_all(&profile).expect("profile dir");
1574 std::fs::write(profile.join("demo_hydrolysis_0000abcd"), []).expect("unit");
1575 let lock_file =
1576 std::fs::File::create(profile.join(".cargo-lock")).expect("create cargo lock");
1577 fs4::FileExt::lock(&lock_file).expect("hold the build lock");
1578
1579 let packages = ["demo-hydrolysis-0000abcd".to_owned()];
1580 let error = super::remove_project_units_in(&target_dir, &packages)
1581 .await
1582 .expect_err("a held build lock must refuse the clean");
1583 assert!(error.to_string().contains("in use"), "{error}");
1584 assert!(profile.join("demo_hydrolysis_0000abcd").is_file());
1585
1586 fs4::FileExt::unlock(&lock_file).expect("release the build lock");
1587 let removed = super::remove_project_units_in(&target_dir, &packages)
1588 .await
1589 .expect("an unlocked target cleans");
1590 assert_eq!(removed, [profile.join("demo_hydrolysis_0000abcd")]);
1591 });
1592 }
1593
1594 #[test]
1595 fn unit_entries_belong_to_their_package_only() {
1596 let package = "demo-hydrolysis-0000abcd";
1597 for name in [
1598 "demo-hydrolysis-0000abcd-1234",
1599 "demo_hydrolysis_0000abcd",
1600 "demo_hydrolysis_0000abcd.exe",
1601 "demo_hydrolysis_0000abcd-1234.d",
1602 "libdemo_hydrolysis_0000abcd-1234.rlib",
1603 "demo_hydrolysis_0000abcd_cef_helper.pdb",
1604 ] {
1605 assert!(super::unit_entry_belongs_to(name, package), "{name}");
1606 }
1607 for name in [
1608 "demo-hydrolysis-0000abcd1-1234",
1609 "demo_hydrolysis_0000abce",
1610 "demo_hydrolysis",
1611 "waterui_dylib.dll",
1612 "libdemo-0000.rlib",
1613 ] {
1614 assert!(!super::unit_entry_belongs_to(name, package), "{name}");
1615 }
1616 }
1617
1618 #[test]
1621 fn shared_target_dir_can_be_dropped_on_demand() {
1622 smol::block_on(async {
1623 let cache_root = tempdir().expect("cache root");
1624 let target_dir = super::ensure_shared_target_dir_in(cache_root.path())
1625 .await
1626 .expect("ensure shared target dir");
1627 smol::fs::create_dir_all(target_dir.join("debug"))
1628 .await
1629 .expect("create a unit dir");
1630 smol::fs::write(target_dir.join("debug/unit.rlib"), [0u8; 1024])
1631 .await
1632 .expect("write a unit");
1633
1634 let freed = super::remove_shared_target_dir_in(cache_root.path())
1635 .await
1636 .expect("drop the shared target");
1637 assert!(
1638 freed.is_some_and(|bytes| bytes > 0),
1639 "the drop reports the space it held: {freed:?}"
1640 );
1641 assert!(!target_dir.exists());
1642 assert_eq!(
1643 super::remove_shared_target_dir_in(cache_root.path())
1644 .await
1645 .expect("a second drop"),
1646 None,
1647 "dropping an absent shared target is a no-op"
1648 );
1649 });
1650 }
1651
1652 #[test]
1653 fn shared_target_dir_is_discovered_and_kept_while_in_use() {
1654 smol::block_on(async {
1655 let project = tempdir().expect("project dir");
1656 let config = WaterConfig::default();
1657 let cache_root = tempdir().expect("cache root");
1658
1659 let target_dir = super::ensure_shared_target_dir_in(cache_root.path())
1660 .await
1661 .expect("ensure shared target dir");
1662
1663 assert_eq!(target_dir, cache_root.path().join("target"));
1664
1665 let discovered = super::discover_managed_build_cache_dirs(cache_root.path())
1666 .await
1667 .expect("discover cache dirs");
1668 assert_eq!(discovered, vec![target_dir.clone()]);
1669
1670 let outcome =
1671 super::cleanup_stale_caches_if_idle(cache_root.path(), project.path(), &config)
1672 .await
1673 .expect("cleanup caches");
1674 assert_eq!(
1675 outcome,
1676 super::BuildCacheGcOutcome::Ran(super::BuildCacheGcSummary {
1677 scanned_entries: 1,
1678 removed_entries: 0,
1679 })
1680 );
1681 assert!(target_dir.exists());
1682 });
1683 }
1684
1685 #[test]
1686 fn stale_shared_target_dir_is_collected() {
1687 smol::block_on(async {
1688 let project = tempdir().expect("project dir");
1689 let config = WaterConfig::default();
1690 let cache_root = tempdir().expect("cache root");
1691
1692 let target_dir = super::ensure_shared_target_dir_in(cache_root.path())
1693 .await
1694 .expect("ensure shared target dir");
1695 let stale_metadata = super::CacheMetadata {
1696 project_root: target_dir.display().to_string(),
1697 cli_commit: CLI_COMMIT.to_string(),
1698 last_used_unix_seconds: 1,
1699 };
1700 smol::fs::write(
1701 metadata_path(&target_dir),
1702 toml::to_string(&stale_metadata).expect("serialize stale metadata"),
1703 )
1704 .await
1705 .expect("write stale metadata");
1706
1707 let outcome =
1708 super::cleanup_stale_caches_if_idle(cache_root.path(), project.path(), &config)
1709 .await
1710 .expect("cleanup caches");
1711 assert_eq!(
1712 outcome,
1713 super::BuildCacheGcOutcome::Ran(super::BuildCacheGcSummary {
1714 scanned_entries: 1,
1715 removed_entries: 1,
1716 })
1717 );
1718 assert!(!target_dir.exists());
1719 });
1720 }
1721}