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}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57/// Cleanup policy for the global managed build cache.
58pub struct BuildCacheConfig {
59    /// Remove build-cache entries that have been unused for more than this many days.
60    #[serde(default = "default_build_cache_cleanup_after_unused_days")]
61    pub cleanup_after_unused_days: u64,
62}
63
64impl Default for BuildCacheConfig {
65    fn default() -> Self {
66        Self {
67            cleanup_after_unused_days: default_build_cache_cleanup_after_unused_days(),
68        }
69    }
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73struct CacheMetadata {
74    project_root: String,
75    cli_commit: String,
76    last_used_unix_seconds: u64,
77}
78
79/// Summary of one managed build-cache garbage-collection pass.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub struct BuildCacheGcSummary {
82    /// Number of managed cache entries inspected, excluding the active project cache.
83    pub scanned_entries: usize,
84    /// Number of stale managed cache entries removed during this pass.
85    pub removed_entries: usize,
86}
87
88/// Result of attempting to garbage-collect stale managed build-cache entries.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum BuildCacheGcOutcome {
91    /// Cleanup ran to completion and produced a removal summary.
92    Ran(BuildCacheGcSummary),
93    /// Cleanup did not run because another `water gc build-cache` process already holds the lock.
94    SkippedAlreadyRunning,
95}
96
97const fn default_build_cache_cleanup_after_unused_days() -> u64 {
98    DEFAULT_BUILD_CACHE_CLEANUP_AFTER_UNUSED_DAYS
99}
100
101/// The current user's home directory could not be determined.
102#[derive(Debug, thiserror::Error)]
103#[error("Could not determine home directory")]
104pub struct HomeDirError;
105
106/// Return the Water home directory root at `~/.water`.
107///
108/// # Errors
109/// Returns an error if the current user's home directory cannot be determined.
110pub fn water_home_dir() -> Result<PathBuf, HomeDirError> {
111    let home = dirs::home_dir().ok_or(HomeDirError)?;
112    Ok(home.join(".water"))
113}
114
115/// Return the Water home directory root at `~/.water` for `host`.
116///
117/// # Errors
118/// Returns an error if the host's home directory cannot be determined.
119pub fn water_home_dir_in(host: &crate::toolchain::Host) -> Result<PathBuf, HomeDirError> {
120    let home = host.home_dir().ok_or(HomeDirError)?;
121    Ok(home.join(".water"))
122}
123
124/// Ensure `~/.water/config.toml` exists and return the parsed configuration.
125///
126/// # Errors
127/// Returns an error if the Water home cannot be created or the config cannot be read or written.
128pub async fn ensure_global_config() -> eyre::Result<WaterConfig> {
129    let water_home = water_home_dir()?;
130    ensure_global_config_in(&water_home).await
131}
132
133/// Return the global managed build-cache root at `~/.water/build_cache`.
134///
135/// # Errors
136/// Returns an error if the Water config cannot be loaded or the cache root cannot be created.
137pub async fn build_cache_root() -> eyre::Result<PathBuf> {
138    let water_home = water_home_dir()?;
139    let (_, cache_root) = resolved_build_cache_root_in(&water_home).await?;
140    Ok(cache_root)
141}
142
143/// Return the Cargo target directory every project's CLI-managed builds share,
144/// creating it if needed.
145///
146/// Compiled units are fingerprint-keyed, so one `~/.water/build_cache/target`
147/// serves every project on the machine: a second `water run` reuses the
148/// dependency graph the first one compiled instead of cold-building it per
149/// project. The directory sits beside the per-project `managed_backends`
150/// containers rather than inside one — generated sources are wiped when the
151/// CLI's scaffold templates change, while compiled artifacts do not go stale
152/// for that reason.
153///
154/// The directory is a first-class cache entry: it carries the same
155/// `metadata.toml` the per-project containers do, so the garbage collector
156/// reports it in usage surveys and reclaims it under the same unused-days
157/// policy once nothing has built for that long. One target also means one
158/// Cargo build-directory lock: builds of different projects serialize, and a
159/// waiting build prints `Blocking waiting for file lock on build directory` —
160/// visible through the piped progress render — for the holder's duration.
161///
162/// # Errors
163/// Returns an error if the Water home cannot be determined, the global config
164/// cannot be loaded, or the cache directory cannot be created.
165pub async fn shared_target_dir() -> eyre::Result<PathBuf> {
166    let cache_root = build_cache_root().await?;
167    ensure_shared_target_dir_in(&cache_root).await
168}
169
170/// Return the shared target directory host-side rlib builds use.
171///
172/// `build_host_rlib` compiles the project's library for the host to read its
173/// `waterui_meta_*` symbols; the dependency graph it compiles is the same for
174/// every project on the machine, so it shares the per-user target root.
175///
176/// # Errors
177/// Returns an error if the shared build-cache directory cannot be resolved.
178pub async fn shared_host_target_dir() -> eyre::Result<PathBuf> {
179    Ok(shared_target_dir().await?.join("host"))
180}
181
182async fn ensure_shared_target_dir_in(cache_root: &Path) -> eyre::Result<PathBuf> {
183    let target_dir = cache_root.join(SHARED_TARGET_DIR_NAME);
184    fs::create_dir_all(&target_dir).await.wrap_err_with(|| {
185        format!(
186            "Failed to create shared target dir {}",
187            target_dir.display()
188        )
189    })?;
190    // `project_root` is the entry itself: it exists exactly as long as the
191    // cache does, so only the unused-days policy can collect it.
192    write_metadata(
193        &target_dir,
194        &CacheMetadata {
195            project_root: target_dir.display().to_string(),
196            cli_commit: CLI_COMMIT.to_string(),
197            last_used_unix_seconds: now_unix_seconds()?,
198        },
199    )
200    .await?;
201    Ok(target_dir)
202}
203
204/// Remove the shared Cargo target directory every project's builds write
205/// into, returning the disk space it held — `None` when no shared target
206/// exists.
207///
208/// The garbage collector only reclaims the directory once it has been unused
209/// for the configured window; this is the explicit drop, for when a user
210/// wants the space back now. Unlike `cargo clean`, it refuses while a Cargo
211/// build is in flight — detected by the `.cargo-lock` every build holds in
212/// its profile directory — since deleting a target mid-build leaves the
213/// survivor's own project with a half-written graph.
214///
215/// # Errors
216/// Returns an error if the cache root cannot be resolved, a Cargo build is
217/// using the directory, or the directory cannot be removed.
218pub async fn remove_shared_target_dir() -> eyre::Result<Option<u64>> {
219    let water_home = water_home_dir()?;
220    let (_, cache_root) = resolved_build_cache_root_in(&water_home).await?;
221    remove_shared_target_dir_in(&cache_root).await
222}
223
224async fn remove_shared_target_dir_in(cache_root: &Path) -> eyre::Result<Option<u64>> {
225    let target_dir = cache_root.join(SHARED_TARGET_DIR_NAME);
226    if !target_dir.exists() {
227        return Ok(None);
228    }
229    shared_target_in_use(&target_dir).await?;
230    let bytes = directory_disk_usage(target_dir.clone()).await?;
231    fs::remove_dir_all(&target_dir).await.wrap_err_with(|| {
232        format!(
233            "Failed to remove shared target dir {}",
234            target_dir.display()
235        )
236    })?;
237    Ok(Some(bytes))
238}
239
240/// Refuse while a Cargo build holds a build-directory lock anywhere under
241/// `target_dir`.
242///
243/// Cargo locks `<triple>/<profile>/.cargo-lock` for the duration of a build,
244/// and those profile dirs sit at most three levels under the shared root —
245/// `<variant>/<triple>/<profile>` — so probing directories only, never
246/// listing profile contents, keeps the check bounded no matter how large the
247/// tree grows.
248async fn shared_target_in_use(target_dir: &Path) -> eyre::Result<()> {
249    let target_dir = target_dir.to_path_buf();
250    smol::unblock(move || -> eyre::Result<()> {
251        let mut profile_dirs = Vec::new();
252        let mut pending = vec![(target_dir.clone(), 0usize)];
253        while let Some((dir, depth)) = pending.pop() {
254            if depth == 3 {
255                continue;
256            }
257            for entry in std::fs::read_dir(&dir)? {
258                let entry = entry?;
259                if entry.file_type()?.is_dir() {
260                    let path = entry.path();
261                    if path.join(".cargo-lock").exists() {
262                        profile_dirs.push(path.clone());
263                    }
264                    pending.push((path, depth + 1));
265                }
266            }
267        }
268        for dir in &profile_dirs {
269            let lock_path = dir.join(".cargo-lock");
270            let file = std::fs::OpenOptions::new()
271                .write(true)
272                .open(&lock_path)
273                .wrap_err_with(|| format!("Failed to open {}", lock_path.display()))?;
274            match FileExt::try_lock(&file) {
275                Ok(()) => {}
276                Err(TryLockError::WouldBlock) => {
277                    return Err(eyre::eyre!(
278                        "the shared Cargo target {} is in use by a running build \
279                         ({} is locked) — drop it once the build finishes",
280                        target_dir.display(),
281                        lock_path.display()
282                    ));
283                }
284                Err(TryLockError::Error(error)) => {
285                    return Err(eyre::Report::from(error))
286                        .wrap_err_with(|| format!("Failed to lock {}", lock_path.display()));
287                }
288            }
289        }
290        Ok(())
291    })
292    .await
293}
294
295/// Return the managed build-cache directory for a project.
296///
297/// # Errors
298/// Returns an error if the project root cannot be canonicalized or the global cache root cannot be resolved.
299pub async fn project_build_cache_dir(project_root: &Path) -> eyre::Result<PathBuf> {
300    let project_root = canonicalize_project_root(project_root)?;
301    let cache_root = build_cache_root().await?;
302    Ok(project_build_cache_dir_in(&project_root, &cache_root))
303}
304
305/// Return the whole managed cache container for a project directory, which need
306/// not exist.
307///
308/// A generated backend workspace lives in the cache rather than in the project,
309/// so a project that has been thrown away leaves its cache behind — and that
310/// cache is what the next build reads. Canonicalizing the project root is not
311/// available in that case, so the nearest existing ancestor is canonicalized
312/// and the rest of the path appended as written.
313///
314/// # Errors
315/// Returns an error if no ancestor of `project_root` can be canonicalized or the
316/// global cache root cannot be resolved.
317pub async fn build_cache_container_for(project_root: &Path) -> eyre::Result<PathBuf> {
318    let mut trailing = Vec::new();
319    let mut existing = project_root.to_path_buf();
320    let resolved = loop {
321        if let Ok(resolved) = existing.canonicalize() {
322            break resolved;
323        }
324        let name = existing.file_name().map(std::ffi::OsString::from);
325        let parent = existing.parent().map(Path::to_path_buf);
326        let (Some(name), Some(parent)) = (name, parent) else {
327            return Err(eyre::eyre!(
328                "Failed to resolve any existing ancestor of {}",
329                project_root.display()
330            ));
331        };
332        trailing.push(name);
333        existing = parent;
334    };
335    let mut project_root = resolved;
336    for name in trailing.iter().rev() {
337        project_root.push(name);
338    }
339    let cache_root = build_cache_root().await?;
340    Ok(project_cache_container_in(&project_root, &cache_root))
341}
342
343/// Ensure the managed build-cache directory exists for a project and return it.
344///
345/// # Errors
346/// Returns an error if the project root cannot be canonicalized, config loading fails, or cache directories cannot be created.
347pub async fn ensure_project_build_cache(project_root: &Path) -> eyre::Result<PathBuf> {
348    let project_root = canonicalize_project_root(project_root)?;
349    let water_home = water_home_dir()?;
350    let (config, cache_root) = resolved_build_cache_root_in(&water_home).await?;
351    if let Err(error) = spawn_build_cache_cleanup_process(&project_root).await {
352        warn!(
353            current_project_root = %project_root.display(),
354            "Failed to spawn build-cache cleanup process: {error}"
355        );
356    }
357    ensure_project_build_cache_in(&project_root, &cache_root, &config).await
358}
359
360/// Garbage-collect stale managed build-cache entries while preserving the current project's cache.
361///
362/// # Errors
363/// Returns an error if the project root cannot be canonicalized, config loading fails,
364/// or stale cache removal fails.
365pub async fn cleanup_stale_build_caches_for_project(
366    project_root: &Path,
367) -> eyre::Result<BuildCacheGcOutcome> {
368    let project_root = canonicalize_project_root(project_root)?;
369    let water_home = water_home_dir()?;
370    let (config, cache_root) = resolved_build_cache_root_in(&water_home).await?;
371    cleanup_stale_caches_if_idle(&cache_root, &project_root, &config).await
372}
373
374async fn spawn_build_cache_cleanup_process(project_root: &Path) -> eyre::Result<()> {
375    let current_executable = std::env::current_exe()
376        .wrap_err("Failed to resolve current water executable for build-cache cleanup")?;
377    let project_root = project_root.to_path_buf();
378
379    smol::unblock(move || -> eyre::Result<()> {
380        std::process::Command::new(&current_executable)
381            .arg("gc")
382            .arg("build-cache")
383            .arg("--path")
384            .arg(&project_root)
385            .stdin(Stdio::null())
386            .stdout(Stdio::null())
387            .stderr(Stdio::null())
388            .spawn()
389            .map(|_| ())
390            .map_err(eyre::Report::from)
391            .wrap_err_with(|| {
392                format!(
393                    "Failed to spawn build-cache cleanup process for {}",
394                    project_root.display()
395                )
396            })
397    })
398    .await
399}
400
401/// Remove the managed build cache for a project.
402///
403/// # Errors
404/// Returns an error if the project root cannot be canonicalized or cache entries cannot be removed.
405pub async fn remove_project_build_cache(project_root: &Path) -> eyre::Result<()> {
406    let project_root = canonicalize_project_root(project_root)?;
407    let cache_root = build_cache_root().await?;
408    remove_project_build_cache_in(&project_root, &cache_root).await
409}
410
411async fn resolved_build_cache_root_in(water_home: &Path) -> eyre::Result<(WaterConfig, PathBuf)> {
412    let config = ensure_global_config_in(water_home).await?;
413    let cache_root = water_home.join(BUILD_CACHE_DIR_NAME);
414    fs::create_dir_all(&cache_root)
415        .await
416        .wrap_err_with(|| format!("Failed to create build cache root {}", cache_root.display()))?;
417    Ok((config, cache_root))
418}
419
420async fn ensure_global_config_in(water_home: &Path) -> eyre::Result<WaterConfig> {
421    fs::create_dir_all(water_home)
422        .await
423        .wrap_err_with(|| format!("Failed to create Water home {}", water_home.display()))?;
424
425    let config_path = water_home.join(CONFIG_FILE_NAME);
426    if config_path.exists() {
427        let contents = fs::read_to_string(&config_path)
428            .await
429            .wrap_err_with(|| format!("Failed to read Water config {}", config_path.display()))?;
430        return toml::from_str(&contents)
431            .wrap_err_with(|| format!("Failed to parse Water config {}", config_path.display()));
432    }
433
434    let config = WaterConfig::default();
435    write_global_config_in(water_home, &config).await?;
436    Ok(config)
437}
438
439/// Persist `config` to `~/.water/config.toml`.
440///
441/// # Errors
442/// Returns an error if the config cannot be serialized or written.
443pub async fn write_global_config(config: &WaterConfig) -> eyre::Result<()> {
444    let water_home = water_home_dir()?;
445    write_global_config_in(&water_home, config).await
446}
447
448async fn write_global_config_in(water_home: &Path, config: &WaterConfig) -> eyre::Result<()> {
449    let config_path = water_home.join(CONFIG_FILE_NAME);
450    let contents = toml::to_string_pretty(config).wrap_err("Failed to serialize Water config")?;
451    fs::write(&config_path, contents)
452        .await
453        .wrap_err_with(|| format!("Failed to write Water config {}", config_path.display()))
454}
455
456async fn ensure_project_build_cache_in(
457    project_root: &Path,
458    cache_root: &Path,
459    _config: &WaterConfig,
460) -> eyre::Result<PathBuf> {
461    remove_legacy_local_water_dir(project_root).await?;
462
463    let cache_dir = project_build_cache_dir_in(project_root, cache_root);
464    if cache_dir.exists() {
465        let should_clean = match read_metadata(&cache_dir).await {
466            Ok(metadata) => {
467                metadata.project_root != project_root.display().to_string()
468                    || metadata.cli_commit != CLI_COMMIT
469            }
470            Err(_) => true,
471        };
472
473        if should_clean {
474            info!(
475                "Managed build cache changed shape, cleaning {}",
476                cache_dir.display()
477            );
478            fs::remove_dir_all(&cache_dir).await?;
479            prune_empty_build_cache_ancestors(
480                cache_root,
481                cache_dir
482                    .parent()
483                    .expect("managed build cache dir should always have a parent"),
484            )
485            .await?;
486        }
487    }
488
489    fs::create_dir_all(&cache_dir)
490        .await
491        .wrap_err_with(|| format!("Failed to create build cache dir {}", cache_dir.display()))?;
492    write_metadata(
493        &cache_dir,
494        &CacheMetadata {
495            project_root: project_root.display().to_string(),
496            cli_commit: CLI_COMMIT.to_string(),
497            last_used_unix_seconds: now_unix_seconds()?,
498        },
499    )
500    .await?;
501
502    Ok(cache_dir)
503}
504
505async fn remove_project_build_cache_in(project_root: &Path, cache_root: &Path) -> eyre::Result<()> {
506    let cache_dir = project_build_cache_dir_in(project_root, cache_root);
507    if cache_dir.exists() {
508        fs::remove_dir_all(&cache_dir).await?;
509        prune_empty_build_cache_ancestors(
510            cache_root,
511            cache_dir
512                .parent()
513                .expect("managed build cache dir should always have a parent"),
514        )
515        .await?;
516    }
517    remove_legacy_local_water_dir(project_root).await
518}
519
520fn project_build_cache_dir_in(project_root: &Path, cache_root: &Path) -> PathBuf {
521    project_cache_container_in(project_root, cache_root).join(MANAGED_BACKENDS_DIR_NAME)
522}
523
524fn project_cache_container_in(project_root: &Path, cache_root: &Path) -> PathBuf {
525    let mut path = cache_root.to_path_buf();
526    for component in project_root.components() {
527        match component {
528            Component::Prefix(prefix) => path.push(normalize_prefix_component(prefix)),
529            Component::RootDir => {}
530            Component::Normal(segment) => path.push(segment),
531            Component::CurDir | Component::ParentDir => {
532                panic!(
533                    "Canonical project root {} must not contain relative path components",
534                    project_root.display()
535                );
536            }
537        }
538    }
539    path
540}
541
542fn normalize_prefix_component(prefix: PrefixComponent<'_>) -> String {
543    match prefix.kind() {
544        Prefix::Disk(letter) | Prefix::VerbatimDisk(letter) => {
545            format!("drive-{}", char::from(letter).to_ascii_uppercase())
546        }
547        Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {
548            format!("unc-{}-{}", sanitize_os_str(server), sanitize_os_str(share))
549        }
550        Prefix::DeviceNS(device) => format!("device-{}", sanitize_os_str(device)),
551        Prefix::Verbatim(component) => format!("verbatim-{}", sanitize_os_str(component)),
552    }
553}
554
555fn sanitize_os_str(value: &OsStr) -> String {
556    let sanitized = value
557        .to_string_lossy()
558        .chars()
559        .map(|character| {
560            if character.is_ascii_alphanumeric() {
561                character
562            } else {
563                '_'
564            }
565        })
566        .collect::<String>();
567    if sanitized.is_empty() {
568        return String::from("empty");
569    }
570    sanitized
571}
572
573fn canonicalize_project_root(project_root: &Path) -> eyre::Result<PathBuf> {
574    project_root.canonicalize().wrap_err_with(|| {
575        format!(
576            "Failed to canonicalize project root {}",
577            project_root.display()
578        )
579    })
580}
581
582fn metadata_path(cache_dir: &Path) -> PathBuf {
583    cache_dir.join(METADATA_FILE_NAME)
584}
585
586async fn read_metadata(cache_dir: &Path) -> eyre::Result<CacheMetadata> {
587    let metadata_path = metadata_path(cache_dir);
588    let contents = fs::read_to_string(&metadata_path)
589        .await
590        .wrap_err_with(|| format!("Failed to read cache metadata {}", metadata_path.display()))?;
591    toml::from_str(&contents)
592        .wrap_err_with(|| format!("Failed to parse cache metadata {}", metadata_path.display()))
593}
594
595async fn write_metadata(cache_dir: &Path, metadata: &CacheMetadata) -> eyre::Result<()> {
596    let metadata_path = metadata_path(cache_dir);
597    let contents = toml::to_string(metadata).wrap_err("Failed to serialize cache metadata")?;
598    fs::write(&metadata_path, contents)
599        .await
600        .wrap_err_with(|| format!("Failed to write cache metadata {}", metadata_path.display()))
601}
602
603async fn remove_legacy_local_water_dir(project_root: &Path) -> eyre::Result<()> {
604    let legacy_dir = project_root.join(LEGACY_LOCAL_WATER_DIR_NAME);
605    if legacy_dir.exists() {
606        info!(
607            "Removing legacy local playground cache at {}",
608            legacy_dir.display()
609        );
610        fs::remove_dir_all(&legacy_dir).await?;
611    }
612    Ok(())
613}
614
615/// On-disk usage of one managed build-cache entry.
616#[derive(Debug, Clone, PartialEq, Eq)]
617pub struct BuildCacheEntryUsage {
618    /// Project the cache entry belongs to.
619    pub project_root: PathBuf,
620    /// Managed cache directory holding the entry.
621    pub cache_dir: PathBuf,
622    /// Disk space the entry occupies, in bytes.
623    pub bytes: u64,
624    /// Whether the entry is currently eligible for removal.
625    pub stale: bool,
626    /// Whether the entry belongs to the project the survey was run from.
627    pub active: bool,
628}
629
630/// What the managed build cache is currently spending disk on.
631#[derive(Debug, Clone, PartialEq, Eq)]
632pub struct BuildCacheUsageReport {
633    /// Entries, largest first.
634    pub entries: Vec<BuildCacheEntryUsage>,
635    /// Total disk space across every entry, in bytes.
636    pub total_bytes: u64,
637    /// Disk space held by entries eligible for removal, in bytes.
638    pub reclaimable_bytes: u64,
639}
640
641/// Survey the managed build cache without removing anything.
642///
643/// Sizes are measured in allocated blocks and each inode is counted once, so
644/// copy-on-write clones and hard links are reported at what they actually cost
645/// rather than at the sum of their apparent lengths.
646///
647/// # Errors
648/// Returns an error if the cache root cannot be read or the Water config cannot be loaded.
649pub async fn survey_build_cache_usage(
650    current_project_root: &Path,
651) -> eyre::Result<BuildCacheUsageReport> {
652    let water_home = water_home_dir()?;
653    let (config, cache_root) = resolved_build_cache_root_in(&water_home).await?;
654    let current_cache_dir = project_build_cache_dir_in(current_project_root, &cache_root);
655    let max_unused_seconds = config
656        .build_cache
657        .cleanup_after_unused_days
658        .saturating_mul(24 * 60 * 60);
659    let now = now_unix_seconds()?;
660
661    let mut entries = Vec::new();
662    for cache_dir in discover_managed_build_cache_dirs(&cache_root).await? {
663        let metadata = read_metadata(&cache_dir).await.ok();
664        let project_root = metadata.as_ref().map_or_else(
665            || cache_dir.clone(),
666            |metadata| PathBuf::from(&metadata.project_root),
667        );
668        let active = cache_dir == current_cache_dir;
669        let stale = !active
670            && metadata.as_ref().is_none_or(|metadata| {
671                !PathBuf::from(&metadata.project_root).exists()
672                    || now.saturating_sub(metadata.last_used_unix_seconds) > max_unused_seconds
673            });
674        let bytes = directory_disk_usage(cache_dir.clone()).await?;
675        entries.push(BuildCacheEntryUsage {
676            project_root,
677            cache_dir,
678            bytes,
679            stale,
680            active,
681        });
682    }
683
684    entries.sort_by(|left, right| {
685        right
686            .bytes
687            .cmp(&left.bytes)
688            .then_with(|| left.cache_dir.cmp(&right.cache_dir))
689    });
690    let total_bytes = entries.iter().map(|entry| entry.bytes).sum();
691    let reclaimable_bytes = entries
692        .iter()
693        .filter(|entry| entry.stale)
694        .map(|entry| entry.bytes)
695        .sum();
696
697    Ok(BuildCacheUsageReport {
698        entries,
699        total_bytes,
700        reclaimable_bytes,
701    })
702}
703
704/// Measure how much disk a directory tree actually occupies.
705async fn directory_disk_usage(root: PathBuf) -> eyre::Result<u64> {
706    smol::unblock(move || {
707        let mut seen_inodes = std::collections::HashSet::new();
708        let mut total = 0u64;
709        for entry in WalkDir::new(&root).follow_links(false) {
710            let Ok(entry) = entry else { continue };
711            if !entry.file_type().is_file() {
712                continue;
713            }
714            let Ok(metadata) = entry.metadata() else {
715                continue;
716            };
717            total = total.saturating_add(file_disk_usage(&metadata, &mut seen_inodes));
718        }
719        Ok(total)
720    })
721    .await
722}
723
724#[cfg(unix)]
725fn file_disk_usage(
726    metadata: &std::fs::Metadata,
727    seen_inodes: &mut std::collections::HashSet<(u64, u64)>,
728) -> u64 {
729    use std::os::unix::fs::MetadataExt as _;
730
731    if !seen_inodes.insert((metadata.dev(), metadata.ino())) {
732        return 0;
733    }
734    // `blocks` is always in 512-byte units, independent of the filesystem's own
735    // block size, and already excludes extents shared with a clone source.
736    metadata.blocks().saturating_mul(512)
737}
738
739#[cfg(not(unix))]
740fn file_disk_usage(
741    metadata: &std::fs::Metadata,
742    _seen_inodes: &mut std::collections::HashSet<(u64, u64)>,
743) -> u64 {
744    metadata.len()
745}
746
747async fn cleanup_stale_caches(
748    cache_root: &Path,
749    current_project_root: &Path,
750    config: &WaterConfig,
751) -> eyre::Result<BuildCacheGcSummary> {
752    fs::create_dir_all(cache_root).await?;
753
754    let current_cache_dir = project_build_cache_dir_in(current_project_root, cache_root);
755    let max_unused_seconds = config
756        .build_cache
757        .cleanup_after_unused_days
758        .saturating_mul(24 * 60 * 60);
759    let now = now_unix_seconds()?;
760
761    let mut scanned_entries = 0usize;
762    let mut removed_entries = 0usize;
763
764    for cache_dir in discover_managed_build_cache_dirs(cache_root).await? {
765        if cache_dir == current_cache_dir {
766            continue;
767        }
768
769        scanned_entries += 1;
770
771        let should_remove = match read_metadata(&cache_dir).await {
772            Ok(metadata) => {
773                let project_root = PathBuf::from(&metadata.project_root);
774                !project_root.exists()
775                    || now.saturating_sub(metadata.last_used_unix_seconds) > max_unused_seconds
776            }
777            Err(error) => {
778                warn!(
779                    "Removing stale build cache with invalid metadata at {}: {error}",
780                    cache_dir.display()
781                );
782                true
783            }
784        };
785
786        if should_remove {
787            if let Err(error) = fs::remove_dir_all(&cache_dir).await {
788                warn!(
789                    "Failed to remove stale build cache {}: {error}",
790                    cache_dir.display()
791                );
792                continue;
793            }
794            prune_empty_build_cache_ancestors(
795                cache_root,
796                cache_dir
797                    .parent()
798                    .expect("managed build cache dir should always have a parent"),
799            )
800            .await?;
801            removed_entries += 1;
802        }
803    }
804
805    Ok(BuildCacheGcSummary {
806        scanned_entries,
807        removed_entries,
808    })
809}
810
811async fn cleanup_stale_caches_if_idle(
812    cache_root: &Path,
813    current_project_root: &Path,
814    config: &WaterConfig,
815) -> eyre::Result<BuildCacheGcOutcome> {
816    let lock_path = cache_root.join(CLEANUP_LOCK_FILE_NAME);
817    let Some(lock_file) = try_acquire_cleanup_lock(&lock_path).await? else {
818        return Ok(BuildCacheGcOutcome::SkippedAlreadyRunning);
819    };
820
821    let cleanup_result = cleanup_stale_caches(cache_root, current_project_root, config).await;
822    // Closing the descriptor releases the lock. The file stays behind on
823    // purpose: it is what the next sweep locks, and leaving it means no exit
824    // path — including one that never runs — can stop cleanup happening again.
825    drop(lock_file);
826
827    cleanup_result.map(BuildCacheGcOutcome::Ran)
828}
829
830/// Takes the cleanup lock, or reports that another sweep already holds it.
831///
832/// The lock has to be released even when the process holding it dies, and
833/// creating the file exclusively is not that: a sweep that is killed leaves the
834/// file behind, and since then every run takes the "already running" path
835/// against a process that no longer exists. Cleanup is spawned detached with its
836/// output discarded, so nothing says so — one interrupted sweep in May left the
837/// cache unswept until August, by which point it had grown to 149 GB, most of it
838/// belonging to workspaces deleted months earlier.
839///
840/// `flock` is released by the kernel when the descriptor closes, which a crash
841/// does too. The file itself stays: it is the thing being locked, not the
842/// signal, so nothing has to remove it and nothing is stranded if a process
843/// dies before it can.
844async fn try_acquire_cleanup_lock(lock_path: &Path) -> eyre::Result<Option<std::fs::File>> {
845    let lock_path = lock_path.to_path_buf();
846    smol::unblock(move || {
847        let file = std::fs::OpenOptions::new()
848            .write(true)
849            .create(true)
850            .truncate(false)
851            .open(&lock_path)
852            .wrap_err_with(|| format!("Failed to open cleanup lock {}", lock_path.display()))?;
853        match FileExt::try_lock(&file) {
854            Ok(()) => Ok(Some(file)),
855            Err(TryLockError::WouldBlock) => Ok(None),
856            Err(TryLockError::Error(error)) => Err(eyre::Report::from(error))
857                .wrap_err_with(|| format!("Failed to lock {}", lock_path.display())),
858        }
859    })
860    .await
861}
862
863/// Every managed cache entry under `cache_root`.
864///
865/// Entries are per-project `managed_backends` containers plus the shared
866/// Cargo `target/` every project's builds write into. Neither interior is
867/// walked: the shared target's marker is read directly — descending into a
868/// cargo target costs as much as the GC itself — and a `managed_backends`
869/// marker is checked the moment its directory surfaces, so the walk only
870/// ever sees the container path hierarchy.
871async fn discover_managed_build_cache_dirs(cache_root: &Path) -> eyre::Result<Vec<PathBuf>> {
872    let cache_root = cache_root.to_path_buf();
873    smol::unblock(move || -> eyre::Result<Vec<PathBuf>> {
874        let shared_target_dir = cache_root.join(SHARED_TARGET_DIR_NAME);
875        let mut cache_dirs = Vec::new();
876        if shared_target_dir.join(METADATA_FILE_NAME).is_file() {
877            cache_dirs.push(shared_target_dir.clone());
878        }
879        for entry in WalkDir::new(&cache_root)
880            .follow_links(false)
881            .into_iter()
882            .filter_entry(|entry| {
883                // A project path can itself end in `target`, so only the
884                // shared target at the root is pruned.
885                if entry.depth() == 1 && entry.path() == shared_target_dir {
886                    return false;
887                }
888                if entry.file_type().is_dir()
889                    && entry.file_name() == OsStr::new(MANAGED_BACKENDS_DIR_NAME)
890                {
891                    if entry.path().join(METADATA_FILE_NAME).is_file() {
892                        cache_dirs.push(entry.path().to_path_buf());
893                    }
894                    return false;
895                }
896                true
897            })
898        {
899            entry.map_err(eyre::Report::from)?;
900        }
901        Ok(cache_dirs)
902    })
903    .await
904}
905
906async fn prune_empty_build_cache_ancestors(
907    cache_root: &Path,
908    starting_dir: &Path,
909) -> eyre::Result<()> {
910    let mut current = starting_dir.to_path_buf();
911    while current.starts_with(cache_root) && current != cache_root {
912        match fs::remove_dir(&current).await {
913            Ok(()) => {
914                let Some(parent) = current.parent() else {
915                    break;
916                };
917                current = parent.to_path_buf();
918            }
919            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
920                let Some(parent) = current.parent() else {
921                    break;
922                };
923                current = parent.to_path_buf();
924            }
925            Err(error) if error.kind() == std::io::ErrorKind::DirectoryNotEmpty => break,
926            Err(error) => return Err(error.into()),
927        }
928    }
929    Ok(())
930}
931
932fn now_unix_seconds() -> eyre::Result<u64> {
933    Ok(SystemTime::now()
934        .duration_since(UNIX_EPOCH)
935        .wrap_err("System clock is before UNIX_EPOCH")?
936        .as_secs())
937}
938
939#[cfg(test)]
940mod tests {
941    use std::path::{Path, PathBuf};
942
943    use tempfile::tempdir;
944
945    use super::{
946        CLI_COMMIT, WaterConfig, ensure_global_config_in, ensure_project_build_cache_in,
947        metadata_path, now_unix_seconds, project_build_cache_dir_in, remove_project_build_cache_in,
948        write_global_config_in,
949    };
950
951    #[test]
952    fn global_config_round_trips_last_used_devices() {
953        smol::block_on(async {
954            let water_home = tempdir().expect("water home");
955
956            let mut config = WaterConfig::default();
957            config.last_used_device.insert(
958                "apple/ios".to_owned(),
959                "00008140-00011C210CF3001C".to_owned(),
960            );
961            config
962                .last_used_device
963                .insert("android/android".to_owned(), "emulator-5554".to_owned());
964            write_global_config_in(water_home.path(), &config)
965                .await
966                .expect("write config");
967
968            let loaded = ensure_global_config_in(water_home.path())
969                .await
970                .expect("read config back");
971            assert_eq!(
972                loaded.last_used_device.get("apple/ios").map(String::as_str),
973                Some("00008140-00011C210CF3001C")
974            );
975            assert_eq!(
976                loaded
977                    .last_used_device
978                    .get("android/android")
979                    .map(String::as_str),
980                Some("emulator-5554")
981            );
982        });
983    }
984
985    #[test]
986    fn ensure_global_config_writes_default_build_cache_policy() {
987        smol::block_on(async {
988            let water_home = tempdir().expect("water home");
989
990            let config = ensure_global_config_in(water_home.path())
991                .await
992                .expect("ensure config");
993
994            assert_eq!(
995                config.build_cache.cleanup_after_unused_days,
996                super::DEFAULT_BUILD_CACHE_CLEANUP_AFTER_UNUSED_DAYS
997            );
998            let saved = smol::fs::read_to_string(water_home.path().join("config.toml"))
999                .await
1000                .expect("read config");
1001            assert!(saved.contains("[build_cache]"));
1002            assert!(saved.contains("cleanup_after_unused_days = 30"));
1003        });
1004    }
1005
1006    #[test]
1007    fn project_build_cache_dir_uses_absolute_project_path_components() {
1008        let cache_root = Path::new("/tmp/water-cache-root");
1009        let project_root = if cfg!(windows) {
1010            PathBuf::from(r"C:\Users\lexo\demo")
1011        } else {
1012            PathBuf::from("/Users/lexo/demo")
1013        };
1014
1015        let cache_dir = project_build_cache_dir_in(&project_root, cache_root);
1016
1017        let expected = if cfg!(windows) {
1018            cache_root.join("drive-C/Users/lexo/demo/managed_backends")
1019        } else {
1020            cache_root.join("Users/lexo/demo/managed_backends")
1021        };
1022        assert_eq!(cache_dir, expected);
1023    }
1024
1025    #[test]
1026    fn ensure_project_build_cache_uses_global_build_cache_dir() {
1027        smol::block_on(async {
1028            let project = tempdir().expect("project dir");
1029            let water_home = tempdir().expect("water home");
1030            let config = ensure_global_config_in(water_home.path())
1031                .await
1032                .expect("ensure config");
1033            let cache_root = water_home.path().join("build_cache");
1034
1035            let cache_dir = ensure_project_build_cache_in(project.path(), &cache_root, &config)
1036                .await
1037                .expect("ensure cache");
1038
1039            assert!(cache_dir.starts_with(&cache_root));
1040            assert_ne!(cache_dir, project.path().join(".water"));
1041            assert!(cache_dir.ends_with("managed_backends"));
1042            assert!(cache_dir.exists());
1043        });
1044    }
1045
1046    #[test]
1047    fn ensure_project_build_cache_removes_legacy_local_water_dir() {
1048        smol::block_on(async {
1049            let project = tempdir().expect("project dir");
1050            let water_home = tempdir().expect("water home");
1051            let config = ensure_global_config_in(water_home.path())
1052                .await
1053                .expect("ensure config");
1054            let cache_root = water_home.path().join("build_cache");
1055            let legacy_dir = project.path().join(".water");
1056            smol::fs::create_dir_all(&legacy_dir)
1057                .await
1058                .expect("create legacy dir");
1059            smol::fs::write(legacy_dir.join("stale"), b"stale")
1060                .await
1061                .expect("write legacy file");
1062
1063            ensure_project_build_cache_in(project.path(), &cache_root, &config)
1064                .await
1065                .expect("ensure cache");
1066
1067            assert!(!legacy_dir.exists());
1068        });
1069    }
1070
1071    #[test]
1072    fn ensure_project_build_cache_cleans_cache_when_cli_commit_changes() {
1073        smol::block_on(async {
1074            let project = tempdir().expect("project dir");
1075            let water_home = tempdir().expect("water home");
1076            let config = ensure_global_config_in(water_home.path())
1077                .await
1078                .expect("ensure config");
1079            let cache_root = water_home.path().join("build_cache");
1080            let cache_dir = project_build_cache_dir_in(project.path(), &cache_root);
1081            smol::fs::create_dir_all(&cache_dir)
1082                .await
1083                .expect("create cache dir");
1084            smol::fs::write(cache_dir.join("stale"), b"stale")
1085                .await
1086                .expect("write stale file");
1087            let stale_metadata = super::CacheMetadata {
1088                project_root: project.path().display().to_string(),
1089                cli_commit: String::from("old-commit"),
1090                last_used_unix_seconds: 1,
1091            };
1092            let stale_contents =
1093                toml::to_string(&stale_metadata).expect("serialize stale metadata");
1094            smol::fs::write(metadata_path(&cache_dir), stale_contents)
1095                .await
1096                .expect("write stale metadata");
1097
1098            ensure_project_build_cache_in(project.path(), &cache_root, &config)
1099                .await
1100                .expect("ensure cache");
1101
1102            assert!(!cache_dir.join("stale").exists());
1103            let fresh_metadata = super::read_metadata(&cache_dir)
1104                .await
1105                .expect("read metadata");
1106            assert_eq!(fresh_metadata.cli_commit, CLI_COMMIT);
1107        });
1108    }
1109
1110    #[test]
1111    fn cleanup_stale_caches_removes_stale_orphaned_caches() {
1112        smol::block_on(async {
1113            let project = tempdir().expect("project dir");
1114            let water_home = tempdir().expect("water home");
1115            let config = WaterConfig::default();
1116            let cache_root = water_home.path().join("build_cache");
1117            let stale_cache = cache_root.join("definitely/missing/project/managed_backends");
1118            smol::fs::create_dir_all(&stale_cache)
1119                .await
1120                .expect("create stale cache");
1121            let stale_metadata = super::CacheMetadata {
1122                project_root: Path::new("/definitely/missing/project")
1123                    .display()
1124                    .to_string(),
1125                cli_commit: CLI_COMMIT.to_string(),
1126                last_used_unix_seconds: now_unix_seconds().expect("now"),
1127            };
1128            let stale_contents =
1129                toml::to_string(&stale_metadata).expect("serialize stale metadata");
1130            smol::fs::write(metadata_path(&stale_cache), stale_contents)
1131                .await
1132                .expect("write stale metadata");
1133
1134            let outcome = super::cleanup_stale_caches_if_idle(&cache_root, project.path(), &config)
1135                .await
1136                .expect("cleanup caches");
1137
1138            assert_eq!(
1139                outcome,
1140                super::BuildCacheGcOutcome::Ran(super::BuildCacheGcSummary {
1141                    scanned_entries: 1,
1142                    removed_entries: 1,
1143                })
1144            );
1145            assert!(!stale_cache.exists());
1146        });
1147    }
1148
1149    #[test]
1150    fn remove_project_build_cache_deletes_only_managed_backends_leaf() {
1151        smol::block_on(async {
1152            let project = tempdir().expect("project dir");
1153            let child_project = project.path().join("nested/child");
1154            smol::fs::create_dir_all(&child_project)
1155                .await
1156                .expect("create child project");
1157            let water_home = tempdir().expect("water home");
1158            let config = ensure_global_config_in(water_home.path())
1159                .await
1160                .expect("ensure config");
1161            let cache_root = water_home.path().join("build_cache");
1162
1163            let parent_cache = ensure_project_build_cache_in(project.path(), &cache_root, &config)
1164                .await
1165                .expect("ensure parent cache");
1166            let child_cache = ensure_project_build_cache_in(&child_project, &cache_root, &config)
1167                .await
1168                .expect("ensure child cache");
1169
1170            remove_project_build_cache_in(project.path(), &cache_root)
1171                .await
1172                .expect("remove parent cache");
1173
1174            assert!(!parent_cache.exists());
1175            assert!(child_cache.exists());
1176        });
1177    }
1178
1179    /// A sweep that dies leaves the lock file behind, because only the happy
1180    /// path could ever remove it. Cleanup must still run afterwards: when it did
1181    /// not, one interrupted sweep stopped every later one for three months, and
1182    /// silently, since cleanup is spawned with its output discarded.
1183    #[test]
1184    fn cleanup_runs_again_after_a_sweep_dies_holding_the_lock() {
1185        smol::block_on(async {
1186            let project = tempdir().expect("project dir");
1187            let water_home = tempdir().expect("water home");
1188            let config = ensure_global_config_in(water_home.path())
1189                .await
1190                .expect("ensure config");
1191            let cache_root = water_home.path().join("build_cache");
1192            smol::fs::create_dir_all(&cache_root)
1193                .await
1194                .expect("create cache root");
1195
1196            // What a killed sweep leaves on disk: the lock file, with no live
1197            // process behind it.
1198            let lock_path = cache_root.join(super::CLEANUP_LOCK_FILE_NAME);
1199            smol::fs::write(&lock_path, [])
1200                .await
1201                .expect("leave a lock file behind");
1202
1203            let stale_project = tempdir().expect("stale project");
1204            let stale_cache =
1205                ensure_project_build_cache_in(stale_project.path(), &cache_root, &config)
1206                    .await
1207                    .expect("ensure stale cache");
1208            drop(stale_project);
1209
1210            let outcome = super::cleanup_stale_caches_if_idle(&cache_root, project.path(), &config)
1211                .await
1212                .expect("cleanup");
1213
1214            assert!(
1215                matches!(outcome, super::BuildCacheGcOutcome::Ran(_)),
1216                "an abandoned lock file must not be read as a running sweep, got {outcome:?}"
1217            );
1218            assert!(!stale_cache.exists());
1219        });
1220    }
1221
1222    /// The other half: while a sweep really is holding the lock, a second one
1223    /// stands down instead of walking the same tree.
1224    #[test]
1225    fn a_second_sweep_stands_down_while_the_first_holds_the_lock() {
1226        smol::block_on(async {
1227            let project = tempdir().expect("project dir");
1228            let water_home = tempdir().expect("water home");
1229            let config = ensure_global_config_in(water_home.path())
1230                .await
1231                .expect("ensure config");
1232            let cache_root = water_home.path().join("build_cache");
1233            smol::fs::create_dir_all(&cache_root)
1234                .await
1235                .expect("create cache root");
1236
1237            let lock_path = cache_root.join(super::CLEANUP_LOCK_FILE_NAME);
1238            let held = super::try_acquire_cleanup_lock(&lock_path)
1239                .await
1240                .expect("acquire lock")
1241                .expect("lock is free");
1242
1243            let outcome = super::cleanup_stale_caches_if_idle(&cache_root, project.path(), &config)
1244                .await
1245                .expect("cleanup");
1246            assert_eq!(outcome, super::BuildCacheGcOutcome::SkippedAlreadyRunning);
1247
1248            drop(held);
1249        });
1250    }
1251
1252    /// Discovery reads the shared target's marker directly and never descends
1253    /// into the tree — a `managed_backends` directory inside `target/` is not
1254    /// a cache entry and must not surface, while the real ones still do.
1255    #[test]
1256    fn discovery_never_walks_inside_the_shared_target() {
1257        smol::block_on(async {
1258            let cache_root = tempdir().expect("cache root");
1259            let target_dir = super::ensure_shared_target_dir_in(cache_root.path())
1260                .await
1261                .expect("ensure shared target dir");
1262            // A marker-shaped dir buried in the target tree — the shape a
1263            // recursive walk would wrongly report.
1264            let buried = target_dir.join("debug/managed_backends");
1265            smol::fs::create_dir_all(&buried)
1266                .await
1267                .expect("create buried dir");
1268            smol::fs::write(
1269                buried.join(super::METADATA_FILE_NAME),
1270                "project_root = \"x\"",
1271            )
1272            .await
1273            .expect("write buried marker");
1274
1275            let project = tempdir().expect("project dir");
1276            let config = WaterConfig::default();
1277            let managed =
1278                super::ensure_project_build_cache_in(project.path(), cache_root.path(), &config)
1279                    .await
1280                    .expect("ensure managed cache");
1281
1282            let mut discovered = super::discover_managed_build_cache_dirs(cache_root.path())
1283                .await
1284                .expect("discover cache dirs");
1285            let mut expected = vec![managed, target_dir];
1286            discovered.sort();
1287            expected.sort();
1288            assert_eq!(discovered, expected);
1289        });
1290    }
1291
1292    /// Dropping the shared target while a Cargo build holds a profile
1293    /// `.cargo-lock` refuses rather than deleting a live build's tree.
1294    #[test]
1295    fn shared_target_dir_refuses_while_a_build_lock_is_held() {
1296        smol::block_on(async {
1297            let cache_root = tempdir().expect("cache root");
1298            let target_dir = super::ensure_shared_target_dir_in(cache_root.path())
1299                .await
1300                .expect("ensure shared target dir");
1301            let profile = target_dir.join("shared/aarch64-apple-darwin/debug");
1302            smol::fs::create_dir_all(&profile)
1303                .await
1304                .expect("create profile dir");
1305            let lock_file =
1306                std::fs::File::create(profile.join(".cargo-lock")).expect("create cargo lock");
1307            fs4::FileExt::lock(&lock_file).expect("hold the build lock");
1308
1309            let error = super::remove_shared_target_dir_in(cache_root.path())
1310                .await
1311                .expect_err("a held build lock must refuse the drop");
1312            assert!(
1313                error.to_string().contains("in use"),
1314                "the error says why: {error}"
1315            );
1316
1317            fs4::FileExt::unlock(&lock_file).expect("release the build lock");
1318            super::remove_shared_target_dir_in(cache_root.path())
1319                .await
1320                .expect("an unlocked target drops")
1321                .expect("the target existed");
1322        });
1323    }
1324
1325    /// The survey reports the shared target, and an explicit drop removes it
1326    /// immediately instead of waiting out the unused-days policy.
1327    #[test]
1328    fn shared_target_dir_can_be_dropped_on_demand() {
1329        smol::block_on(async {
1330            let cache_root = tempdir().expect("cache root");
1331            let target_dir = super::ensure_shared_target_dir_in(cache_root.path())
1332                .await
1333                .expect("ensure shared target dir");
1334            smol::fs::create_dir_all(target_dir.join("debug"))
1335                .await
1336                .expect("create a unit dir");
1337            smol::fs::write(target_dir.join("debug/unit.rlib"), [0u8; 1024])
1338                .await
1339                .expect("write a unit");
1340
1341            let freed = super::remove_shared_target_dir_in(cache_root.path())
1342                .await
1343                .expect("drop the shared target");
1344            assert!(
1345                freed.is_some_and(|bytes| bytes > 0),
1346                "the drop reports the space it held: {freed:?}"
1347            );
1348            assert!(!target_dir.exists());
1349            assert_eq!(
1350                super::remove_shared_target_dir_in(cache_root.path())
1351                    .await
1352                    .expect("a second drop"),
1353                None,
1354                "dropping an absent shared target is a no-op"
1355            );
1356        });
1357    }
1358
1359    #[test]
1360    fn shared_target_dir_is_discovered_and_kept_while_in_use() {
1361        smol::block_on(async {
1362            let project = tempdir().expect("project dir");
1363            let config = WaterConfig::default();
1364            let cache_root = tempdir().expect("cache root");
1365
1366            let target_dir = super::ensure_shared_target_dir_in(cache_root.path())
1367                .await
1368                .expect("ensure shared target dir");
1369
1370            assert_eq!(target_dir, cache_root.path().join("target"));
1371
1372            let discovered = super::discover_managed_build_cache_dirs(cache_root.path())
1373                .await
1374                .expect("discover cache dirs");
1375            assert_eq!(discovered, vec![target_dir.clone()]);
1376
1377            let outcome =
1378                super::cleanup_stale_caches_if_idle(cache_root.path(), project.path(), &config)
1379                    .await
1380                    .expect("cleanup caches");
1381            assert_eq!(
1382                outcome,
1383                super::BuildCacheGcOutcome::Ran(super::BuildCacheGcSummary {
1384                    scanned_entries: 1,
1385                    removed_entries: 0,
1386                })
1387            );
1388            assert!(target_dir.exists());
1389        });
1390    }
1391
1392    #[test]
1393    fn stale_shared_target_dir_is_collected() {
1394        smol::block_on(async {
1395            let project = tempdir().expect("project dir");
1396            let config = WaterConfig::default();
1397            let cache_root = tempdir().expect("cache root");
1398
1399            let target_dir = super::ensure_shared_target_dir_in(cache_root.path())
1400                .await
1401                .expect("ensure shared target dir");
1402            let stale_metadata = super::CacheMetadata {
1403                project_root: target_dir.display().to_string(),
1404                cli_commit: CLI_COMMIT.to_string(),
1405                last_used_unix_seconds: 1,
1406            };
1407            smol::fs::write(
1408                metadata_path(&target_dir),
1409                toml::to_string(&stale_metadata).expect("serialize stale metadata"),
1410            )
1411            .await
1412            .expect("write stale metadata");
1413
1414            let outcome =
1415                super::cleanup_stale_caches_if_idle(cache_root.path(), project.path(), &config)
1416                    .await
1417                    .expect("cleanup caches");
1418            assert_eq!(
1419                outcome,
1420                super::BuildCacheGcOutcome::Ran(super::BuildCacheGcSummary {
1421                    scanned_entries: 1,
1422                    removed_entries: 1,
1423                })
1424            );
1425            assert!(!target_dir.exists());
1426        });
1427    }
1428}