Skip to main content

waterui_cli/project_model/
water_dir.rs

1//! Management of the global Water home and per-project managed backend build cache.
2//!
3//! Playground projects store generated backends under
4//! `~/.water/build_cache/<absolute-project-path>/managed_backends/` instead of
5//! scattering `.water` directories into user projects. Compiled Cargo artifacts
6//! live in the sibling `~/.water/build_cache/target/` — one directory shared by
7//! every project, so a machine compiles each dependency revision once no matter
8//! how many projects use it. The price of that sharing is serialization: Cargo
9//! takes one build-directory lock per target, so concurrent `water` invocations
10//! on different projects build one at a time — the second reports `Blocking
11//! waiting for file lock on build directory`, which the piped progress render
12//! surfaces rather than swallowing — instead of each compiling the same
13//! dependency graph in parallel. Sharing wins overall for the workflows the
14//! CLI targets, where the alternative is N cold framework compiles.
15
16use 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
30/// The CLI commit hash embedded at build time.
31pub 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)]
43/// Global Water CLI configuration persisted to `~/.water/config.toml`.
44pub struct WaterConfig {
45    /// Managed build-cache policy.
46    #[serde(default)]
47    pub build_cache: BuildCacheConfig,
48    /// The device `water run` last used per target, keyed by
49    /// `"<backend>/<platform>"` (for example `"apple/ios"`). The value is the
50    /// device's stable identifier — a simulator UDID, a physical-device
51    /// identifier, an Android serial, or an AVD name.
52    #[serde(default)]
53    pub last_used_device: std::collections::BTreeMap<String, String>,
54    /// Unix seconds of the last passive `water update` check, which runs at
55    /// most once per 24 hours.
56    #[serde(default)]
57    pub last_update_check_unix_seconds: Option<u64>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61/// Cleanup policy for the global managed build cache.
62pub struct BuildCacheConfig {
63    /// Remove build-cache entries that have been unused for more than this many days.
64    #[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/// Summary of one managed build-cache garbage-collection pass.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub struct BuildCacheGcSummary {
86    /// Number of managed cache entries inspected, excluding the active project cache.
87    pub scanned_entries: usize,
88    /// Number of stale managed cache entries removed during this pass.
89    pub removed_entries: usize,
90}
91
92/// Result of attempting to garbage-collect stale managed build-cache entries.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum BuildCacheGcOutcome {
95    /// Cleanup ran to completion and produced a removal summary.
96    Ran(BuildCacheGcSummary),
97    /// Cleanup did not run because another `water gc build-cache` process already holds the lock.
98    SkippedAlreadyRunning,
99}
100
101const fn default_build_cache_cleanup_after_unused_days() -> u64 {
102    DEFAULT_BUILD_CACHE_CLEANUP_AFTER_UNUSED_DAYS
103}
104
105/// The current user's home directory could not be determined.
106#[derive(Debug, thiserror::Error)]
107#[error("Could not determine home directory")]
108pub struct HomeDirError;
109
110/// Return the Water home directory root at `~/.water`.
111///
112/// # Errors
113/// Returns an error if the current user's home directory cannot be determined.
114pub fn water_home_dir() -> Result<PathBuf, HomeDirError> {
115    let home = dirs::home_dir().ok_or(HomeDirError)?;
116    Ok(home.join(".water"))
117}
118
119/// Return the Water home directory root at `~/.water` for `host`.
120///
121/// # Errors
122/// Returns an error if the host's home directory cannot be determined.
123pub 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
128/// Ensure `~/.water/config.toml` exists and return the parsed configuration.
129///
130/// # Errors
131/// Returns an error if the Water home cannot be created or the config cannot be read or written.
132pub 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
137/// Return the global managed build-cache root at `~/.water/build_cache`.
138///
139/// # Errors
140/// Returns an error if the Water config cannot be loaded or the cache root cannot be created.
141pub 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
147/// Return the Cargo target directory every project's CLI-managed builds share,
148/// creating it if needed.
149///
150/// Compiled units are fingerprint-keyed, so one `~/.water/build_cache/target`
151/// serves every project on the machine: a second `water run` reuses the
152/// dependency graph the first one compiled instead of cold-building it per
153/// project. The directory sits beside the per-project `managed_backends`
154/// containers rather than inside one — generated sources are wiped when the
155/// CLI's scaffold templates change, while compiled artifacts do not go stale
156/// for that reason.
157///
158/// The directory is a first-class cache entry: it carries the same
159/// `metadata.toml` the per-project containers do, so the garbage collector
160/// reports it in usage surveys and reclaims it under the same unused-days
161/// policy once nothing has built for that long. One target also means one
162/// Cargo build-directory lock: builds of different projects serialize, and a
163/// waiting build prints `Blocking waiting for file lock on build directory` —
164/// visible through the piped progress render — for the holder's duration.
165///
166/// # Errors
167/// Returns an error if the Water home cannot be determined, the global config
168/// cannot be loaded, or the cache directory cannot be created.
169pub 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
174/// Return the shared target directory host-side rlib builds use.
175///
176/// `build_host_rlib` compiles the project's library for the host to read its
177/// `waterui_meta_*` symbols; the dependency graph it compiles is the same for
178/// every project on the machine, so it shares the per-user target root.
179///
180/// # Errors
181/// Returns an error if the shared build-cache directory cannot be resolved.
182pub 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    // `project_root` is the entry itself: it exists exactly as long as the
195    // cache does, so only the unused-days policy can collect it.
196    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
208/// Remove the shared Cargo target directory every project's builds write
209/// into, returning the disk space it held — `None` when no shared target
210/// exists.
211///
212/// The garbage collector only reclaims the directory once it has been unused
213/// for the configured window; this is the explicit drop, for when a user
214/// wants the space back now. Unlike `cargo clean`, it refuses while a Cargo
215/// build is in flight — detected by the `.cargo-lock` every build holds in
216/// its profile directory — since deleting a target mid-build leaves the
217/// survivor's own project with a half-written graph.
218///
219/// # Errors
220/// Returns an error if the cache root cannot be resolved, a Cargo build is
221/// using the directory, or the directory cannot be removed.
222pub 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
244/// Refuse while a Cargo build holds a build-directory lock anywhere under
245/// `target_dir`.
246///
247/// Cargo locks `<triple>/<profile>/.cargo-lock` for the duration of a build,
248/// and those profile dirs sit at most three levels under the shared root —
249/// `<variant>/<triple>/<profile>` — so probing directories only, never
250/// listing profile contents, keeps the check bounded no matter how large the
251/// tree grows.
252async 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
299/// Return the managed build-cache directory for a project.
300///
301/// # Errors
302/// Returns an error if the project root cannot be canonicalized or the global cache root cannot be resolved.
303pub 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
309/// Return the whole managed cache container for a project directory, which need
310/// not exist.
311///
312/// A generated backend workspace lives in the cache rather than in the project,
313/// so a project that has been thrown away leaves its cache behind — and that
314/// cache is what the next build reads. Canonicalizing the project root is not
315/// available in that case, so the nearest existing ancestor is canonicalized
316/// and the rest of the path appended as written.
317///
318/// # Errors
319/// Returns an error if no ancestor of `project_root` can be canonicalized or the
320/// global cache root cannot be resolved.
321pub 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
347/// Ensure the managed build-cache directory exists for a project and return it.
348///
349/// # Errors
350/// Returns an error if the project root cannot be canonicalized, config loading fails, or cache directories cannot be created.
351pub 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
364/// Garbage-collect stale managed build-cache entries while preserving the current project's cache.
365///
366/// # Errors
367/// Returns an error if the project root cannot be canonicalized, config loading fails,
368/// or stale cache removal fails.
369pub 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(&current_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
405/// Remove the managed build cache for a project.
406///
407/// # Errors
408/// Returns an error if the project root cannot be canonicalized or cache entries cannot be removed.
409pub 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
443/// Persist `config` to `~/.water/config.toml`.
444///
445/// # Errors
446/// Returns an error if the config cannot be serialized or written.
447pub 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/// On-disk usage of one managed build-cache entry.
623#[derive(Debug, Clone, PartialEq, Eq)]
624pub struct BuildCacheEntryUsage {
625    /// Project the cache entry belongs to.
626    pub project_root: PathBuf,
627    /// Managed cache directory holding the entry.
628    pub cache_dir: PathBuf,
629    /// Disk space the entry occupies, in bytes.
630    pub bytes: u64,
631    /// Whether the entry is currently eligible for removal.
632    pub stale: bool,
633    /// Whether the entry belongs to the project the survey was run from.
634    pub active: bool,
635}
636
637/// What the managed build cache is currently spending disk on.
638#[derive(Debug, Clone, PartialEq, Eq)]
639pub struct BuildCacheUsageReport {
640    /// Entries, largest first.
641    pub entries: Vec<BuildCacheEntryUsage>,
642    /// Total disk space across every entry, in bytes.
643    pub total_bytes: u64,
644    /// Disk space held by entries eligible for removal, in bytes.
645    pub reclaimable_bytes: u64,
646}
647
648/// Survey the managed build cache without removing anything.
649///
650/// Sizes are measured in allocated blocks and each inode is counted once, so
651/// copy-on-write clones and hard links are reported at what they actually cost
652/// rather than at the sum of their apparent lengths.
653///
654/// # Errors
655/// Returns an error if the cache root cannot be read or the Water config cannot be loaded.
656pub 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
711/// Measure how much disk a directory tree actually occupies.
712async 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    // `blocks` is always in 512-byte units, independent of the filesystem's own
742    // block size, and already excludes extents shared with a clone source.
743    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    // Closing the descriptor releases the lock. The file stays behind on
830    // purpose: it is what the next sweep locks, and leaving it means no exit
831    // path — including one that never runs — can stop cleanup happening again.
832    drop(lock_file);
833
834    cleanup_result.map(BuildCacheGcOutcome::Ran)
835}
836
837/// Takes the cleanup lock, or reports that another sweep already holds it.
838///
839/// The lock has to be released even when the process holding it dies, and
840/// creating the file exclusively is not that: a sweep that is killed leaves the
841/// file behind, and since then every run takes the "already running" path
842/// against a process that no longer exists. Cleanup is spawned detached with its
843/// output discarded, so nothing says so — one interrupted sweep in May left the
844/// cache unswept until August, by which point it had grown to 149 GB, most of it
845/// belonging to workspaces deleted months earlier.
846///
847/// `flock` is released by the kernel when the descriptor closes, which a crash
848/// does too. The file itself stays: it is the thing being locked, not the
849/// signal, so nothing has to remove it and nothing is stranded if a process
850/// dies before it can.
851async 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
870/// Every managed cache entry under `cache_root`.
871///
872/// Entries are per-project `managed_backends` containers plus the shared
873/// Cargo `target/` every project's builds write into. Neither interior is
874/// walked: the shared target's marker is read directly — descending into a
875/// cargo target costs as much as the GC itself — and a `managed_backends`
876/// marker is checked the moment its directory surfaces, so the walk only
877/// ever sees the container path hierarchy.
878async 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                // A project path can itself end in `target`, so only the
891                // shared target at the root is pruned.
892                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(&current).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    /// A sweep that dies leaves the lock file behind, because only the happy
1187    /// path could ever remove it. Cleanup must still run afterwards: when it did
1188    /// not, one interrupted sweep stopped every later one for three months, and
1189    /// silently, since cleanup is spawned with its output discarded.
1190    #[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            // What a killed sweep leaves on disk: the lock file, with no live
1204            // process behind it.
1205            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    /// The other half: while a sweep really is holding the lock, a second one
1230    /// stands down instead of walking the same tree.
1231    #[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    /// Discovery reads the shared target's marker directly and never descends
1260    /// into the tree — a `managed_backends` directory inside `target/` is not
1261    /// a cache entry and must not surface, while the real ones still do.
1262    #[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            // A marker-shaped dir buried in the target tree — the shape a
1270            // recursive walk would wrongly report.
1271            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    /// Dropping the shared target while a Cargo build holds a profile
1300    /// `.cargo-lock` refuses rather than deleting a live build's tree.
1301    #[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    /// The survey reports the shared target, and an explicit drop removes it
1333    /// immediately instead of waiting out the unused-days policy.
1334    #[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}