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
186/// The path of the shared Cargo target directory, without creating it or
187/// touching its metadata — for messages that name it.
188///
189/// # Errors
190/// Returns an error if the Water home or the global config cannot be resolved.
191pub async fn shared_target_dir_path() -> eyre::Result<PathBuf> {
192    let water_home = water_home_dir()?;
193    let (_, cache_root) = resolved_build_cache_root_in(&water_home).await?;
194    Ok(cache_root.join(SHARED_TARGET_DIR_NAME))
195}
196
197async fn ensure_shared_target_dir_in(cache_root: &Path) -> eyre::Result<PathBuf> {
198    let target_dir = cache_root.join(SHARED_TARGET_DIR_NAME);
199    fs::create_dir_all(&target_dir).await.wrap_err_with(|| {
200        format!(
201            "Failed to create shared target dir {}",
202            target_dir.display()
203        )
204    })?;
205    // `project_root` is the entry itself: it exists exactly as long as the
206    // cache does, so only the unused-days policy can collect it.
207    write_metadata(
208        &target_dir,
209        &CacheMetadata {
210            project_root: target_dir.display().to_string(),
211            cli_commit: CLI_COMMIT.to_string(),
212            last_used_unix_seconds: now_unix_seconds()?,
213        },
214    )
215    .await?;
216    Ok(target_dir)
217}
218
219/// Remove the shared Cargo target directory every project's builds write
220/// into, returning the disk space it held — `None` when no shared target
221/// exists.
222///
223/// The garbage collector only reclaims the directory once it has been unused
224/// for the configured window; this is the explicit drop, for when a user
225/// wants the space back now. Unlike `cargo clean`, it refuses while a Cargo
226/// build is in flight — detected by the `.cargo-lock` every build holds in
227/// its profile directory — since deleting a target mid-build leaves the
228/// survivor's own project with a half-written graph.
229///
230/// # Errors
231/// Returns an error if the cache root cannot be resolved, a Cargo build is
232/// using the directory, or the directory cannot be removed.
233pub async fn remove_shared_target_dir() -> eyre::Result<Option<u64>> {
234    let water_home = water_home_dir()?;
235    let (_, cache_root) = resolved_build_cache_root_in(&water_home).await?;
236    remove_shared_target_dir_in(&cache_root).await
237}
238
239async fn remove_shared_target_dir_in(cache_root: &Path) -> eyre::Result<Option<u64>> {
240    let target_dir = cache_root.join(SHARED_TARGET_DIR_NAME);
241    if !target_dir.exists() {
242        return Ok(None);
243    }
244    shared_target_in_use(&target_dir).await?;
245    let bytes = directory_disk_usage(target_dir.clone()).await?;
246    fs::remove_dir_all(&target_dir).await.wrap_err_with(|| {
247        format!(
248            "Failed to remove shared target dir {}",
249            target_dir.display()
250        )
251    })?;
252    Ok(Some(bytes))
253}
254
255/// Remove one project's own units from the shared Cargo target directory,
256/// returning the paths removed.
257///
258/// `packages` are the project's generated crate names — tagged with the
259/// project root's hash (see `generated_crate_name`), so nothing another
260/// project compiled carries them. Every profile directory under the shared
261/// root (`<variant>/<triple>/<profile>`, at most three levels deep and marked
262/// by Cargo's `.cargo-lock`) is swept: the uplifted outputs in the profile
263/// directory itself and the entries in `deps/`, `.fingerprint/`, `build/` and
264/// `incremental/` that Cargo names after those packages. Dependency artifacts
265/// — the framework, the shared runtime, everything a package by another name
266/// produced — stay for the other projects that resolve them identically;
267/// `remove_shared_target_dir` is the only operation that drops those.
268///
269/// This is the filesystem effect of `cargo clean --package` for each name, done
270/// without Cargo because a playground's generated manifests are removed by
271/// the same clean and Cargo needs them — and their resolved dependency graph —
272/// to compute the units.
273///
274/// # Errors
275/// Returns an error if the cache root cannot be resolved, a Cargo build is
276/// using the directory, or an entry cannot be removed.
277pub async fn remove_project_units_from_shared_target(
278    packages: &[String],
279) -> eyre::Result<Vec<PathBuf>> {
280    remove_project_units_in(&shared_target_dir_path().await?, packages).await
281}
282
283async fn remove_project_units_in(
284    target_dir: &Path,
285    packages: &[String],
286) -> eyre::Result<Vec<PathBuf>> {
287    if !target_dir.exists() || packages.is_empty() {
288        return Ok(Vec::new());
289    }
290    shared_target_in_use(target_dir).await?;
291    let target_dir = target_dir.to_path_buf();
292    let packages = packages.to_vec();
293    smol::unblock(move || -> eyre::Result<Vec<PathBuf>> {
294        let mut removed = Vec::new();
295        let mut pending = vec![(target_dir, 0usize)];
296        while let Some((dir, depth)) = pending.pop() {
297            if dir.join(".cargo-lock").is_file() {
298                removed.extend(remove_package_units_in_profile(&dir, &packages)?);
299                continue;
300            }
301            if depth == 3 {
302                continue;
303            }
304            for entry in std::fs::read_dir(&dir)
305                .wrap_err_with(|| format!("Failed to read {}", dir.display()))?
306            {
307                let entry = entry?;
308                if entry.file_type()?.is_dir() {
309                    pending.push((entry.path(), depth + 1));
310                }
311            }
312        }
313        removed.sort();
314        Ok(removed)
315    })
316    .await
317}
318
319/// Cargo's per-profile subdirectories whose direct children are named after
320/// the unit's package or target.
321const PROFILE_UNIT_DIRS: [&str; 4] = ["deps", ".fingerprint", "build", "incremental"];
322
323fn remove_package_units_in_profile(
324    profile_dir: &Path,
325    packages: &[String],
326) -> eyre::Result<Vec<PathBuf>> {
327    let mut removed = Vec::new();
328    let mut dirs = vec![profile_dir.to_path_buf()];
329    dirs.extend(
330        PROFILE_UNIT_DIRS
331            .iter()
332            .map(|name| profile_dir.join(name))
333            .filter(|dir| dir.is_dir()),
334    );
335    for dir in dirs {
336        for entry in
337            std::fs::read_dir(&dir).wrap_err_with(|| format!("Failed to read {}", dir.display()))?
338        {
339            let entry = entry?;
340            let file_name = entry.file_name();
341            let Some(name) = file_name.to_str() else {
342                continue;
343            };
344            if !packages
345                .iter()
346                .any(|package| unit_entry_belongs_to(name, package))
347            {
348                continue;
349            }
350            let path = entry.path();
351            if entry.file_type()?.is_dir() {
352                std::fs::remove_dir_all(&path)
353            } else {
354                std::fs::remove_file(&path)
355            }
356            .wrap_err_with(|| format!("Failed to remove {}", path.display()))?;
357            removed.push(path);
358        }
359    }
360    Ok(removed)
361}
362
363/// Whether a Cargo target-directory entry belongs to `package`.
364///
365/// Cargo spells a package `foo-bar` as `foo-bar-<hash>` in `.fingerprint/`
366/// and `build/`, and its targets as `foo_bar`, `foo_bar-<hash>` or
367/// `libfoo_bar-<hash>.<ext>` elsewhere; an uplifted output may carry an
368/// extension straight after the name (`foo_bar.exe`, `foo_bar.pdb`), and a
369/// further target of the same package extends the name with `_`
370/// (`foo_bar_cef_helper`). Any other continuation is a different name.
371fn unit_entry_belongs_to(entry_name: &str, package: &str) -> bool {
372    let target_name = package.replace('-', "_");
373    let stem = entry_name.strip_prefix("lib").unwrap_or(entry_name);
374    [entry_name, stem].into_iter().any(|candidate| {
375        [package, target_name.as_str()].into_iter().any(|name| {
376            candidate
377                .strip_prefix(name)
378                .is_some_and(|rest| rest.is_empty() || rest.starts_with(['-', '.', '_']))
379        })
380    })
381}
382
383/// Refuse while a Cargo build holds a build-directory lock anywhere under
384/// `target_dir`.
385///
386/// Cargo locks `<triple>/<profile>/.cargo-lock` for the duration of a build,
387/// and those profile dirs sit at most three levels under the shared root —
388/// `<variant>/<triple>/<profile>` — so probing directories only, never
389/// listing profile contents, keeps the check bounded no matter how large the
390/// tree grows.
391async fn shared_target_in_use(target_dir: &Path) -> eyre::Result<()> {
392    let target_dir = target_dir.to_path_buf();
393    smol::unblock(move || -> eyre::Result<()> {
394        let mut profile_dirs = Vec::new();
395        let mut pending = vec![(target_dir.clone(), 0usize)];
396        while let Some((dir, depth)) = pending.pop() {
397            if depth == 3 {
398                continue;
399            }
400            for entry in std::fs::read_dir(&dir)? {
401                let entry = entry?;
402                if entry.file_type()?.is_dir() {
403                    let path = entry.path();
404                    if path.join(".cargo-lock").exists() {
405                        profile_dirs.push(path.clone());
406                    }
407                    pending.push((path, depth + 1));
408                }
409            }
410        }
411        for dir in &profile_dirs {
412            let lock_path = dir.join(".cargo-lock");
413            let file = std::fs::OpenOptions::new()
414                .write(true)
415                .open(&lock_path)
416                .wrap_err_with(|| format!("Failed to open {}", lock_path.display()))?;
417            match FileExt::try_lock(&file) {
418                Ok(()) => {}
419                Err(TryLockError::WouldBlock) => {
420                    return Err(eyre::eyre!(
421                        "the shared Cargo target {} is in use by a running build \
422                         ({} is locked) — drop it once the build finishes",
423                        target_dir.display(),
424                        lock_path.display()
425                    ));
426                }
427                Err(TryLockError::Error(error)) => {
428                    return Err(eyre::Report::from(error))
429                        .wrap_err_with(|| format!("Failed to lock {}", lock_path.display()));
430                }
431            }
432        }
433        Ok(())
434    })
435    .await
436}
437
438/// Return the managed build-cache directory for a project.
439///
440/// # Errors
441/// Returns an error if the project root cannot be canonicalized or the global cache root cannot be resolved.
442pub async fn project_build_cache_dir(project_root: &Path) -> eyre::Result<PathBuf> {
443    let project_root = canonicalize_project_root(project_root)?;
444    let cache_root = build_cache_root().await?;
445    Ok(project_build_cache_dir_in(&project_root, &cache_root))
446}
447
448/// Return the whole managed cache container for a project directory, which need
449/// not exist.
450///
451/// A generated backend workspace lives in the cache rather than in the project,
452/// so a project that has been thrown away leaves its cache behind — and that
453/// cache is what the next build reads. Canonicalizing the project root is not
454/// available in that case, so the nearest existing ancestor is canonicalized
455/// and the rest of the path appended as written.
456///
457/// # Errors
458/// Returns an error if no ancestor of `project_root` can be canonicalized or the
459/// global cache root cannot be resolved.
460pub async fn build_cache_container_for(project_root: &Path) -> eyre::Result<PathBuf> {
461    let mut trailing = Vec::new();
462    let mut existing = project_root.to_path_buf();
463    let resolved = loop {
464        if let Ok(resolved) = existing.canonicalize() {
465            break resolved;
466        }
467        let name = existing.file_name().map(std::ffi::OsString::from);
468        let parent = existing.parent().map(Path::to_path_buf);
469        let (Some(name), Some(parent)) = (name, parent) else {
470            return Err(eyre::eyre!(
471                "Failed to resolve any existing ancestor of {}",
472                project_root.display()
473            ));
474        };
475        trailing.push(name);
476        existing = parent;
477    };
478    let mut project_root = resolved;
479    for name in trailing.iter().rev() {
480        project_root.push(name);
481    }
482    let cache_root = build_cache_root().await?;
483    Ok(project_cache_container_in(&project_root, &cache_root))
484}
485
486/// Ensure the managed build-cache directory exists for a project and return it.
487///
488/// # Errors
489/// Returns an error if the project root cannot be canonicalized, config loading fails, or cache directories cannot be created.
490pub async fn ensure_project_build_cache(project_root: &Path) -> eyre::Result<PathBuf> {
491    let project_root = canonicalize_project_root(project_root)?;
492    let water_home = water_home_dir()?;
493    let (config, cache_root) = resolved_build_cache_root_in(&water_home).await?;
494    if let Err(error) = spawn_build_cache_cleanup_process(&project_root).await {
495        warn!(
496            current_project_root = %project_root.display(),
497            "Failed to spawn build-cache cleanup process: {error}"
498        );
499    }
500    ensure_project_build_cache_in(&project_root, &cache_root, &config).await
501}
502
503/// Garbage-collect stale managed build-cache entries while preserving the current project's cache.
504///
505/// # Errors
506/// Returns an error if the project root cannot be canonicalized, config loading fails,
507/// or stale cache removal fails.
508pub async fn cleanup_stale_build_caches_for_project(
509    project_root: &Path,
510) -> eyre::Result<BuildCacheGcOutcome> {
511    let project_root = canonicalize_project_root(project_root)?;
512    let water_home = water_home_dir()?;
513    let (config, cache_root) = resolved_build_cache_root_in(&water_home).await?;
514    cleanup_stale_caches_if_idle(&cache_root, &project_root, &config).await
515}
516
517async fn spawn_build_cache_cleanup_process(project_root: &Path) -> eyre::Result<()> {
518    let current_executable = std::env::current_exe()
519        .wrap_err("Failed to resolve current water executable for build-cache cleanup")?;
520    let project_root = project_root.to_path_buf();
521
522    smol::unblock(move || -> eyre::Result<()> {
523        std::process::Command::new(&current_executable)
524            .arg("gc")
525            .arg("build-cache")
526            .arg("--path")
527            .arg(&project_root)
528            .stdin(Stdio::null())
529            .stdout(Stdio::null())
530            .stderr(Stdio::null())
531            .spawn()
532            .map(|_| ())
533            .map_err(eyre::Report::from)
534            .wrap_err_with(|| {
535                format!(
536                    "Failed to spawn build-cache cleanup process for {}",
537                    project_root.display()
538                )
539            })
540    })
541    .await
542}
543
544/// Remove the managed build cache for a project.
545///
546/// # Errors
547/// Returns an error if the project root cannot be canonicalized or cache entries cannot be removed.
548pub async fn remove_project_build_cache(project_root: &Path) -> eyre::Result<()> {
549    let project_root = canonicalize_project_root(project_root)?;
550    let cache_root = build_cache_root().await?;
551    remove_project_build_cache_in(&project_root, &cache_root).await
552}
553
554async fn resolved_build_cache_root_in(water_home: &Path) -> eyre::Result<(WaterConfig, PathBuf)> {
555    let config = ensure_global_config_in(water_home).await?;
556    let cache_root = water_home.join(BUILD_CACHE_DIR_NAME);
557    fs::create_dir_all(&cache_root)
558        .await
559        .wrap_err_with(|| format!("Failed to create build cache root {}", cache_root.display()))?;
560    Ok((config, cache_root))
561}
562
563pub(crate) async fn ensure_global_config_in(water_home: &Path) -> eyre::Result<WaterConfig> {
564    fs::create_dir_all(water_home)
565        .await
566        .wrap_err_with(|| format!("Failed to create Water home {}", water_home.display()))?;
567
568    let config_path = water_home.join(CONFIG_FILE_NAME);
569    if config_path.exists() {
570        let contents = fs::read_to_string(&config_path)
571            .await
572            .wrap_err_with(|| format!("Failed to read Water config {}", config_path.display()))?;
573        return toml::from_str(&contents)
574            .wrap_err_with(|| format!("Failed to parse Water config {}", config_path.display()));
575    }
576
577    let config = WaterConfig::default();
578    write_global_config_in(water_home, &config).await?;
579    Ok(config)
580}
581
582/// Persist `config` to `~/.water/config.toml`.
583///
584/// # Errors
585/// Returns an error if the config cannot be serialized or written.
586pub async fn write_global_config(config: &WaterConfig) -> eyre::Result<()> {
587    let water_home = water_home_dir()?;
588    write_global_config_in(&water_home, config).await
589}
590
591pub(crate) async fn write_global_config_in(
592    water_home: &Path,
593    config: &WaterConfig,
594) -> eyre::Result<()> {
595    let config_path = water_home.join(CONFIG_FILE_NAME);
596    let contents = toml::to_string_pretty(config).wrap_err("Failed to serialize Water config")?;
597    fs::write(&config_path, contents)
598        .await
599        .wrap_err_with(|| format!("Failed to write Water config {}", config_path.display()))
600}
601
602async fn ensure_project_build_cache_in(
603    project_root: &Path,
604    cache_root: &Path,
605    _config: &WaterConfig,
606) -> eyre::Result<PathBuf> {
607    remove_legacy_local_water_dir(project_root).await?;
608
609    let cache_dir = project_build_cache_dir_in(project_root, cache_root);
610    if cache_dir.exists() {
611        let should_clean = match read_metadata(&cache_dir).await {
612            Ok(metadata) => {
613                metadata.project_root != project_root.display().to_string()
614                    || metadata.cli_commit != CLI_COMMIT
615            }
616            Err(_) => true,
617        };
618
619        if should_clean {
620            info!(
621                "Managed build cache changed shape, cleaning {}",
622                cache_dir.display()
623            );
624            fs::remove_dir_all(&cache_dir).await?;
625            prune_empty_build_cache_ancestors(
626                cache_root,
627                cache_dir
628                    .parent()
629                    .expect("managed build cache dir should always have a parent"),
630            )
631            .await?;
632        }
633    }
634
635    fs::create_dir_all(&cache_dir)
636        .await
637        .wrap_err_with(|| format!("Failed to create build cache dir {}", cache_dir.display()))?;
638    write_metadata(
639        &cache_dir,
640        &CacheMetadata {
641            project_root: project_root.display().to_string(),
642            cli_commit: CLI_COMMIT.to_string(),
643            last_used_unix_seconds: now_unix_seconds()?,
644        },
645    )
646    .await?;
647
648    Ok(cache_dir)
649}
650
651async fn remove_project_build_cache_in(project_root: &Path, cache_root: &Path) -> eyre::Result<()> {
652    let cache_dir = project_build_cache_dir_in(project_root, cache_root);
653    if cache_dir.exists() {
654        fs::remove_dir_all(&cache_dir).await?;
655        prune_empty_build_cache_ancestors(
656            cache_root,
657            cache_dir
658                .parent()
659                .expect("managed build cache dir should always have a parent"),
660        )
661        .await?;
662    }
663    remove_legacy_local_water_dir(project_root).await
664}
665
666fn project_build_cache_dir_in(project_root: &Path, cache_root: &Path) -> PathBuf {
667    project_cache_container_in(project_root, cache_root).join(MANAGED_BACKENDS_DIR_NAME)
668}
669
670fn project_cache_container_in(project_root: &Path, cache_root: &Path) -> PathBuf {
671    let mut path = cache_root.to_path_buf();
672    for component in project_root.components() {
673        match component {
674            Component::Prefix(prefix) => path.push(normalize_prefix_component(prefix)),
675            Component::RootDir => {}
676            Component::Normal(segment) => path.push(segment),
677            Component::CurDir | Component::ParentDir => {
678                panic!(
679                    "Canonical project root {} must not contain relative path components",
680                    project_root.display()
681                );
682            }
683        }
684    }
685    path
686}
687
688fn normalize_prefix_component(prefix: PrefixComponent<'_>) -> String {
689    match prefix.kind() {
690        Prefix::Disk(letter) | Prefix::VerbatimDisk(letter) => {
691            format!("drive-{}", char::from(letter).to_ascii_uppercase())
692        }
693        Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {
694            format!("unc-{}-{}", sanitize_os_str(server), sanitize_os_str(share))
695        }
696        Prefix::DeviceNS(device) => format!("device-{}", sanitize_os_str(device)),
697        Prefix::Verbatim(component) => format!("verbatim-{}", sanitize_os_str(component)),
698    }
699}
700
701fn sanitize_os_str(value: &OsStr) -> String {
702    let sanitized = value
703        .to_string_lossy()
704        .chars()
705        .map(|character| {
706            if character.is_ascii_alphanumeric() {
707                character
708            } else {
709                '_'
710            }
711        })
712        .collect::<String>();
713    if sanitized.is_empty() {
714        return String::from("empty");
715    }
716    sanitized
717}
718
719fn canonicalize_project_root(project_root: &Path) -> eyre::Result<PathBuf> {
720    project_root.canonicalize().wrap_err_with(|| {
721        format!(
722            "Failed to canonicalize project root {}",
723            project_root.display()
724        )
725    })
726}
727
728fn metadata_path(cache_dir: &Path) -> PathBuf {
729    cache_dir.join(METADATA_FILE_NAME)
730}
731
732async fn read_metadata(cache_dir: &Path) -> eyre::Result<CacheMetadata> {
733    let metadata_path = metadata_path(cache_dir);
734    let contents = fs::read_to_string(&metadata_path)
735        .await
736        .wrap_err_with(|| format!("Failed to read cache metadata {}", metadata_path.display()))?;
737    toml::from_str(&contents)
738        .wrap_err_with(|| format!("Failed to parse cache metadata {}", metadata_path.display()))
739}
740
741async fn write_metadata(cache_dir: &Path, metadata: &CacheMetadata) -> eyre::Result<()> {
742    let metadata_path = metadata_path(cache_dir);
743    let contents = toml::to_string(metadata).wrap_err("Failed to serialize cache metadata")?;
744    fs::write(&metadata_path, contents)
745        .await
746        .wrap_err_with(|| format!("Failed to write cache metadata {}", metadata_path.display()))
747}
748
749async fn remove_legacy_local_water_dir(project_root: &Path) -> eyre::Result<()> {
750    let legacy_dir = project_root.join(LEGACY_LOCAL_WATER_DIR_NAME);
751    if legacy_dir.exists() {
752        info!(
753            "Removing legacy local playground cache at {}",
754            legacy_dir.display()
755        );
756        fs::remove_dir_all(&legacy_dir).await?;
757    }
758    Ok(())
759}
760
761/// On-disk usage of one managed build-cache entry.
762#[derive(Debug, Clone, PartialEq, Eq)]
763pub struct BuildCacheEntryUsage {
764    /// Project the cache entry belongs to.
765    pub project_root: PathBuf,
766    /// Managed cache directory holding the entry.
767    pub cache_dir: PathBuf,
768    /// Disk space the entry occupies, in bytes.
769    pub bytes: u64,
770    /// Whether the entry is currently eligible for removal.
771    pub stale: bool,
772    /// Whether the entry belongs to the project the survey was run from.
773    pub active: bool,
774}
775
776/// What the managed build cache is currently spending disk on.
777#[derive(Debug, Clone, PartialEq, Eq)]
778pub struct BuildCacheUsageReport {
779    /// Entries, largest first.
780    pub entries: Vec<BuildCacheEntryUsage>,
781    /// Total disk space across every entry, in bytes.
782    pub total_bytes: u64,
783    /// Disk space held by entries eligible for removal, in bytes.
784    pub reclaimable_bytes: u64,
785}
786
787/// Survey the managed build cache without removing anything.
788///
789/// Sizes are measured in allocated blocks and each inode is counted once, so
790/// copy-on-write clones and hard links are reported at what they actually cost
791/// rather than at the sum of their apparent lengths.
792///
793/// # Errors
794/// Returns an error if the cache root cannot be read or the Water config cannot be loaded.
795pub async fn survey_build_cache_usage(
796    current_project_root: &Path,
797) -> eyre::Result<BuildCacheUsageReport> {
798    let water_home = water_home_dir()?;
799    let (config, cache_root) = resolved_build_cache_root_in(&water_home).await?;
800    let current_cache_dir = project_build_cache_dir_in(current_project_root, &cache_root);
801    let max_unused_seconds = config
802        .build_cache
803        .cleanup_after_unused_days
804        .saturating_mul(24 * 60 * 60);
805    let now = now_unix_seconds()?;
806
807    let mut entries = Vec::new();
808    for cache_dir in discover_managed_build_cache_dirs(&cache_root).await? {
809        let metadata = read_metadata(&cache_dir).await.ok();
810        let project_root = metadata.as_ref().map_or_else(
811            || cache_dir.clone(),
812            |metadata| PathBuf::from(&metadata.project_root),
813        );
814        let active = cache_dir == current_cache_dir;
815        let stale = !active
816            && metadata.as_ref().is_none_or(|metadata| {
817                !PathBuf::from(&metadata.project_root).exists()
818                    || now.saturating_sub(metadata.last_used_unix_seconds) > max_unused_seconds
819            });
820        let bytes = directory_disk_usage(cache_dir.clone()).await?;
821        entries.push(BuildCacheEntryUsage {
822            project_root,
823            cache_dir,
824            bytes,
825            stale,
826            active,
827        });
828    }
829
830    entries.sort_by(|left, right| {
831        right
832            .bytes
833            .cmp(&left.bytes)
834            .then_with(|| left.cache_dir.cmp(&right.cache_dir))
835    });
836    let total_bytes = entries.iter().map(|entry| entry.bytes).sum();
837    let reclaimable_bytes = entries
838        .iter()
839        .filter(|entry| entry.stale)
840        .map(|entry| entry.bytes)
841        .sum();
842
843    Ok(BuildCacheUsageReport {
844        entries,
845        total_bytes,
846        reclaimable_bytes,
847    })
848}
849
850/// Measure how much disk a directory tree actually occupies.
851async fn directory_disk_usage(root: PathBuf) -> eyre::Result<u64> {
852    smol::unblock(move || {
853        let mut seen_inodes = std::collections::HashSet::new();
854        let mut total = 0u64;
855        for entry in WalkDir::new(&root).follow_links(false) {
856            let Ok(entry) = entry else { continue };
857            if !entry.file_type().is_file() {
858                continue;
859            }
860            let Ok(metadata) = entry.metadata() else {
861                continue;
862            };
863            total = total.saturating_add(file_disk_usage(&metadata, &mut seen_inodes));
864        }
865        Ok(total)
866    })
867    .await
868}
869
870#[cfg(unix)]
871fn file_disk_usage(
872    metadata: &std::fs::Metadata,
873    seen_inodes: &mut std::collections::HashSet<(u64, u64)>,
874) -> u64 {
875    use std::os::unix::fs::MetadataExt as _;
876
877    if !seen_inodes.insert((metadata.dev(), metadata.ino())) {
878        return 0;
879    }
880    // `blocks` is always in 512-byte units, independent of the filesystem's own
881    // block size, and already excludes extents shared with a clone source.
882    metadata.blocks().saturating_mul(512)
883}
884
885#[cfg(not(unix))]
886fn file_disk_usage(
887    metadata: &std::fs::Metadata,
888    _seen_inodes: &mut std::collections::HashSet<(u64, u64)>,
889) -> u64 {
890    metadata.len()
891}
892
893async fn cleanup_stale_caches(
894    cache_root: &Path,
895    current_project_root: &Path,
896    config: &WaterConfig,
897) -> eyre::Result<BuildCacheGcSummary> {
898    fs::create_dir_all(cache_root).await?;
899
900    let current_cache_dir = project_build_cache_dir_in(current_project_root, cache_root);
901    let max_unused_seconds = config
902        .build_cache
903        .cleanup_after_unused_days
904        .saturating_mul(24 * 60 * 60);
905    let now = now_unix_seconds()?;
906
907    let mut scanned_entries = 0usize;
908    let mut removed_entries = 0usize;
909
910    for cache_dir in discover_managed_build_cache_dirs(cache_root).await? {
911        if cache_dir == current_cache_dir {
912            continue;
913        }
914
915        scanned_entries += 1;
916
917        let should_remove = match read_metadata(&cache_dir).await {
918            Ok(metadata) => {
919                let project_root = PathBuf::from(&metadata.project_root);
920                !project_root.exists()
921                    || now.saturating_sub(metadata.last_used_unix_seconds) > max_unused_seconds
922            }
923            Err(error) => {
924                warn!(
925                    "Removing stale build cache with invalid metadata at {}: {error}",
926                    cache_dir.display()
927                );
928                true
929            }
930        };
931
932        if should_remove {
933            if let Err(error) = fs::remove_dir_all(&cache_dir).await {
934                warn!(
935                    "Failed to remove stale build cache {}: {error}",
936                    cache_dir.display()
937                );
938                continue;
939            }
940            prune_empty_build_cache_ancestors(
941                cache_root,
942                cache_dir
943                    .parent()
944                    .expect("managed build cache dir should always have a parent"),
945            )
946            .await?;
947            removed_entries += 1;
948        }
949    }
950
951    Ok(BuildCacheGcSummary {
952        scanned_entries,
953        removed_entries,
954    })
955}
956
957async fn cleanup_stale_caches_if_idle(
958    cache_root: &Path,
959    current_project_root: &Path,
960    config: &WaterConfig,
961) -> eyre::Result<BuildCacheGcOutcome> {
962    let lock_path = cache_root.join(CLEANUP_LOCK_FILE_NAME);
963    let Some(lock_file) = try_acquire_cleanup_lock(&lock_path).await? else {
964        return Ok(BuildCacheGcOutcome::SkippedAlreadyRunning);
965    };
966
967    let cleanup_result = cleanup_stale_caches(cache_root, current_project_root, config).await;
968    // Closing the descriptor releases the lock. The file stays behind on
969    // purpose: it is what the next sweep locks, and leaving it means no exit
970    // path — including one that never runs — can stop cleanup happening again.
971    drop(lock_file);
972
973    cleanup_result.map(BuildCacheGcOutcome::Ran)
974}
975
976/// Takes the cleanup lock, or reports that another sweep already holds it.
977///
978/// The lock has to be released even when the process holding it dies, and
979/// creating the file exclusively is not that: a sweep that is killed leaves the
980/// file behind, and since then every run takes the "already running" path
981/// against a process that no longer exists. Cleanup is spawned detached with its
982/// output discarded, so nothing says so — one interrupted sweep in May left the
983/// cache unswept until August, by which point it had grown to 149 GB, most of it
984/// belonging to workspaces deleted months earlier.
985///
986/// `flock` is released by the kernel when the descriptor closes, which a crash
987/// does too. The file itself stays: it is the thing being locked, not the
988/// signal, so nothing has to remove it and nothing is stranded if a process
989/// dies before it can.
990async fn try_acquire_cleanup_lock(lock_path: &Path) -> eyre::Result<Option<std::fs::File>> {
991    let lock_path = lock_path.to_path_buf();
992    smol::unblock(move || {
993        let file = std::fs::OpenOptions::new()
994            .write(true)
995            .create(true)
996            .truncate(false)
997            .open(&lock_path)
998            .wrap_err_with(|| format!("Failed to open cleanup lock {}", lock_path.display()))?;
999        match FileExt::try_lock(&file) {
1000            Ok(()) => Ok(Some(file)),
1001            Err(TryLockError::WouldBlock) => Ok(None),
1002            Err(TryLockError::Error(error)) => Err(eyre::Report::from(error))
1003                .wrap_err_with(|| format!("Failed to lock {}", lock_path.display())),
1004        }
1005    })
1006    .await
1007}
1008
1009/// Every managed cache entry under `cache_root`.
1010///
1011/// Entries are per-project `managed_backends` containers plus the shared
1012/// Cargo `target/` every project's builds write into. Neither interior is
1013/// walked: the shared target's marker is read directly — descending into a
1014/// cargo target costs as much as the GC itself — and a `managed_backends`
1015/// marker is checked the moment its directory surfaces, so the walk only
1016/// ever sees the container path hierarchy.
1017async fn discover_managed_build_cache_dirs(cache_root: &Path) -> eyre::Result<Vec<PathBuf>> {
1018    let cache_root = cache_root.to_path_buf();
1019    smol::unblock(move || -> eyre::Result<Vec<PathBuf>> {
1020        let shared_target_dir = cache_root.join(SHARED_TARGET_DIR_NAME);
1021        let mut cache_dirs = Vec::new();
1022        if shared_target_dir.join(METADATA_FILE_NAME).is_file() {
1023            cache_dirs.push(shared_target_dir.clone());
1024        }
1025        for entry in WalkDir::new(&cache_root)
1026            .follow_links(false)
1027            .into_iter()
1028            .filter_entry(|entry| {
1029                // A project path can itself end in `target`, so only the
1030                // shared target at the root is pruned.
1031                if entry.depth() == 1 && entry.path() == shared_target_dir {
1032                    return false;
1033                }
1034                if entry.file_type().is_dir()
1035                    && entry.file_name() == OsStr::new(MANAGED_BACKENDS_DIR_NAME)
1036                {
1037                    if entry.path().join(METADATA_FILE_NAME).is_file() {
1038                        cache_dirs.push(entry.path().to_path_buf());
1039                    }
1040                    return false;
1041                }
1042                true
1043            })
1044        {
1045            entry.map_err(eyre::Report::from)?;
1046        }
1047        Ok(cache_dirs)
1048    })
1049    .await
1050}
1051
1052async fn prune_empty_build_cache_ancestors(
1053    cache_root: &Path,
1054    starting_dir: &Path,
1055) -> eyre::Result<()> {
1056    let mut current = starting_dir.to_path_buf();
1057    while current.starts_with(cache_root) && current != cache_root {
1058        match fs::remove_dir(&current).await {
1059            Ok(()) => {
1060                let Some(parent) = current.parent() else {
1061                    break;
1062                };
1063                current = parent.to_path_buf();
1064            }
1065            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1066                let Some(parent) = current.parent() else {
1067                    break;
1068                };
1069                current = parent.to_path_buf();
1070            }
1071            Err(error) if error.kind() == std::io::ErrorKind::DirectoryNotEmpty => break,
1072            Err(error) => return Err(error.into()),
1073        }
1074    }
1075    Ok(())
1076}
1077
1078fn now_unix_seconds() -> eyre::Result<u64> {
1079    Ok(SystemTime::now()
1080        .duration_since(UNIX_EPOCH)
1081        .wrap_err("System clock is before UNIX_EPOCH")?
1082        .as_secs())
1083}
1084
1085#[cfg(test)]
1086mod tests {
1087    use std::path::{Path, PathBuf};
1088
1089    use tempfile::tempdir;
1090
1091    use super::{
1092        CLI_COMMIT, WaterConfig, ensure_global_config_in, ensure_project_build_cache_in,
1093        metadata_path, now_unix_seconds, project_build_cache_dir_in, remove_project_build_cache_in,
1094        write_global_config_in,
1095    };
1096
1097    #[test]
1098    fn global_config_round_trips_last_used_devices() {
1099        smol::block_on(async {
1100            let water_home = tempdir().expect("water home");
1101
1102            let mut config = WaterConfig::default();
1103            config.last_used_device.insert(
1104                "apple/ios".to_owned(),
1105                "00008140-00011C210CF3001C".to_owned(),
1106            );
1107            config
1108                .last_used_device
1109                .insert("android/android".to_owned(), "emulator-5554".to_owned());
1110            write_global_config_in(water_home.path(), &config)
1111                .await
1112                .expect("write config");
1113
1114            let loaded = ensure_global_config_in(water_home.path())
1115                .await
1116                .expect("read config back");
1117            assert_eq!(
1118                loaded.last_used_device.get("apple/ios").map(String::as_str),
1119                Some("00008140-00011C210CF3001C")
1120            );
1121            assert_eq!(
1122                loaded
1123                    .last_used_device
1124                    .get("android/android")
1125                    .map(String::as_str),
1126                Some("emulator-5554")
1127            );
1128        });
1129    }
1130
1131    #[test]
1132    fn ensure_global_config_writes_default_build_cache_policy() {
1133        smol::block_on(async {
1134            let water_home = tempdir().expect("water home");
1135
1136            let config = ensure_global_config_in(water_home.path())
1137                .await
1138                .expect("ensure config");
1139
1140            assert_eq!(
1141                config.build_cache.cleanup_after_unused_days,
1142                super::DEFAULT_BUILD_CACHE_CLEANUP_AFTER_UNUSED_DAYS
1143            );
1144            let saved = smol::fs::read_to_string(water_home.path().join("config.toml"))
1145                .await
1146                .expect("read config");
1147            assert!(saved.contains("[build_cache]"));
1148            assert!(saved.contains("cleanup_after_unused_days = 30"));
1149        });
1150    }
1151
1152    #[test]
1153    fn project_build_cache_dir_uses_absolute_project_path_components() {
1154        let cache_root = Path::new("/tmp/water-cache-root");
1155        let project_root = if cfg!(windows) {
1156            PathBuf::from(r"C:\Users\lexo\demo")
1157        } else {
1158            PathBuf::from("/Users/lexo/demo")
1159        };
1160
1161        let cache_dir = project_build_cache_dir_in(&project_root, cache_root);
1162
1163        let expected = if cfg!(windows) {
1164            cache_root.join("drive-C/Users/lexo/demo/managed_backends")
1165        } else {
1166            cache_root.join("Users/lexo/demo/managed_backends")
1167        };
1168        assert_eq!(cache_dir, expected);
1169    }
1170
1171    #[test]
1172    fn ensure_project_build_cache_uses_global_build_cache_dir() {
1173        smol::block_on(async {
1174            let project = tempdir().expect("project dir");
1175            let water_home = tempdir().expect("water home");
1176            let config = ensure_global_config_in(water_home.path())
1177                .await
1178                .expect("ensure config");
1179            let cache_root = water_home.path().join("build_cache");
1180
1181            let cache_dir = ensure_project_build_cache_in(project.path(), &cache_root, &config)
1182                .await
1183                .expect("ensure cache");
1184
1185            assert!(cache_dir.starts_with(&cache_root));
1186            assert_ne!(cache_dir, project.path().join(".water"));
1187            assert!(cache_dir.ends_with("managed_backends"));
1188            assert!(cache_dir.exists());
1189        });
1190    }
1191
1192    #[test]
1193    fn ensure_project_build_cache_removes_legacy_local_water_dir() {
1194        smol::block_on(async {
1195            let project = tempdir().expect("project dir");
1196            let water_home = tempdir().expect("water home");
1197            let config = ensure_global_config_in(water_home.path())
1198                .await
1199                .expect("ensure config");
1200            let cache_root = water_home.path().join("build_cache");
1201            let legacy_dir = project.path().join(".water");
1202            smol::fs::create_dir_all(&legacy_dir)
1203                .await
1204                .expect("create legacy dir");
1205            smol::fs::write(legacy_dir.join("stale"), b"stale")
1206                .await
1207                .expect("write legacy file");
1208
1209            ensure_project_build_cache_in(project.path(), &cache_root, &config)
1210                .await
1211                .expect("ensure cache");
1212
1213            assert!(!legacy_dir.exists());
1214        });
1215    }
1216
1217    #[test]
1218    fn ensure_project_build_cache_cleans_cache_when_cli_commit_changes() {
1219        smol::block_on(async {
1220            let project = tempdir().expect("project dir");
1221            let water_home = tempdir().expect("water home");
1222            let config = ensure_global_config_in(water_home.path())
1223                .await
1224                .expect("ensure config");
1225            let cache_root = water_home.path().join("build_cache");
1226            let cache_dir = project_build_cache_dir_in(project.path(), &cache_root);
1227            smol::fs::create_dir_all(&cache_dir)
1228                .await
1229                .expect("create cache dir");
1230            smol::fs::write(cache_dir.join("stale"), b"stale")
1231                .await
1232                .expect("write stale file");
1233            let stale_metadata = super::CacheMetadata {
1234                project_root: project.path().display().to_string(),
1235                cli_commit: String::from("old-commit"),
1236                last_used_unix_seconds: 1,
1237            };
1238            let stale_contents =
1239                toml::to_string(&stale_metadata).expect("serialize stale metadata");
1240            smol::fs::write(metadata_path(&cache_dir), stale_contents)
1241                .await
1242                .expect("write stale metadata");
1243
1244            ensure_project_build_cache_in(project.path(), &cache_root, &config)
1245                .await
1246                .expect("ensure cache");
1247
1248            assert!(!cache_dir.join("stale").exists());
1249            let fresh_metadata = super::read_metadata(&cache_dir)
1250                .await
1251                .expect("read metadata");
1252            assert_eq!(fresh_metadata.cli_commit, CLI_COMMIT);
1253        });
1254    }
1255
1256    #[test]
1257    fn cleanup_stale_caches_removes_stale_orphaned_caches() {
1258        smol::block_on(async {
1259            let project = tempdir().expect("project dir");
1260            let water_home = tempdir().expect("water home");
1261            let config = WaterConfig::default();
1262            let cache_root = water_home.path().join("build_cache");
1263            let stale_cache = cache_root.join("definitely/missing/project/managed_backends");
1264            smol::fs::create_dir_all(&stale_cache)
1265                .await
1266                .expect("create stale cache");
1267            let stale_metadata = super::CacheMetadata {
1268                project_root: Path::new("/definitely/missing/project")
1269                    .display()
1270                    .to_string(),
1271                cli_commit: CLI_COMMIT.to_string(),
1272                last_used_unix_seconds: now_unix_seconds().expect("now"),
1273            };
1274            let stale_contents =
1275                toml::to_string(&stale_metadata).expect("serialize stale metadata");
1276            smol::fs::write(metadata_path(&stale_cache), stale_contents)
1277                .await
1278                .expect("write stale metadata");
1279
1280            let outcome = super::cleanup_stale_caches_if_idle(&cache_root, project.path(), &config)
1281                .await
1282                .expect("cleanup caches");
1283
1284            assert_eq!(
1285                outcome,
1286                super::BuildCacheGcOutcome::Ran(super::BuildCacheGcSummary {
1287                    scanned_entries: 1,
1288                    removed_entries: 1,
1289                })
1290            );
1291            assert!(!stale_cache.exists());
1292        });
1293    }
1294
1295    #[test]
1296    fn remove_project_build_cache_deletes_only_managed_backends_leaf() {
1297        smol::block_on(async {
1298            let project = tempdir().expect("project dir");
1299            let child_project = project.path().join("nested/child");
1300            smol::fs::create_dir_all(&child_project)
1301                .await
1302                .expect("create child project");
1303            let water_home = tempdir().expect("water home");
1304            let config = ensure_global_config_in(water_home.path())
1305                .await
1306                .expect("ensure config");
1307            let cache_root = water_home.path().join("build_cache");
1308
1309            let parent_cache = ensure_project_build_cache_in(project.path(), &cache_root, &config)
1310                .await
1311                .expect("ensure parent cache");
1312            let child_cache = ensure_project_build_cache_in(&child_project, &cache_root, &config)
1313                .await
1314                .expect("ensure child cache");
1315
1316            remove_project_build_cache_in(project.path(), &cache_root)
1317                .await
1318                .expect("remove parent cache");
1319
1320            assert!(!parent_cache.exists());
1321            assert!(child_cache.exists());
1322        });
1323    }
1324
1325    /// A sweep that dies leaves the lock file behind, because only the happy
1326    /// path could ever remove it. Cleanup must still run afterwards: when it did
1327    /// not, one interrupted sweep stopped every later one for three months, and
1328    /// silently, since cleanup is spawned with its output discarded.
1329    #[test]
1330    fn cleanup_runs_again_after_a_sweep_dies_holding_the_lock() {
1331        smol::block_on(async {
1332            let project = tempdir().expect("project dir");
1333            let water_home = tempdir().expect("water home");
1334            let config = ensure_global_config_in(water_home.path())
1335                .await
1336                .expect("ensure config");
1337            let cache_root = water_home.path().join("build_cache");
1338            smol::fs::create_dir_all(&cache_root)
1339                .await
1340                .expect("create cache root");
1341
1342            // What a killed sweep leaves on disk: the lock file, with no live
1343            // process behind it.
1344            let lock_path = cache_root.join(super::CLEANUP_LOCK_FILE_NAME);
1345            smol::fs::write(&lock_path, [])
1346                .await
1347                .expect("leave a lock file behind");
1348
1349            let stale_project = tempdir().expect("stale project");
1350            let stale_cache =
1351                ensure_project_build_cache_in(stale_project.path(), &cache_root, &config)
1352                    .await
1353                    .expect("ensure stale cache");
1354            drop(stale_project);
1355
1356            let outcome = super::cleanup_stale_caches_if_idle(&cache_root, project.path(), &config)
1357                .await
1358                .expect("cleanup");
1359
1360            assert!(
1361                matches!(outcome, super::BuildCacheGcOutcome::Ran(_)),
1362                "an abandoned lock file must not be read as a running sweep, got {outcome:?}"
1363            );
1364            assert!(!stale_cache.exists());
1365        });
1366    }
1367
1368    /// The other half: while a sweep really is holding the lock, a second one
1369    /// stands down instead of walking the same tree.
1370    #[test]
1371    fn a_second_sweep_stands_down_while_the_first_holds_the_lock() {
1372        smol::block_on(async {
1373            let project = tempdir().expect("project dir");
1374            let water_home = tempdir().expect("water home");
1375            let config = ensure_global_config_in(water_home.path())
1376                .await
1377                .expect("ensure config");
1378            let cache_root = water_home.path().join("build_cache");
1379            smol::fs::create_dir_all(&cache_root)
1380                .await
1381                .expect("create cache root");
1382
1383            let lock_path = cache_root.join(super::CLEANUP_LOCK_FILE_NAME);
1384            let held = super::try_acquire_cleanup_lock(&lock_path)
1385                .await
1386                .expect("acquire lock")
1387                .expect("lock is free");
1388
1389            let outcome = super::cleanup_stale_caches_if_idle(&cache_root, project.path(), &config)
1390                .await
1391                .expect("cleanup");
1392            assert_eq!(outcome, super::BuildCacheGcOutcome::SkippedAlreadyRunning);
1393
1394            drop(held);
1395        });
1396    }
1397
1398    /// Discovery reads the shared target's marker directly and never descends
1399    /// into the tree — a `managed_backends` directory inside `target/` is not
1400    /// a cache entry and must not surface, while the real ones still do.
1401    #[test]
1402    fn discovery_never_walks_inside_the_shared_target() {
1403        smol::block_on(async {
1404            let cache_root = tempdir().expect("cache root");
1405            let target_dir = super::ensure_shared_target_dir_in(cache_root.path())
1406                .await
1407                .expect("ensure shared target dir");
1408            // A marker-shaped dir buried in the target tree — the shape a
1409            // recursive walk would wrongly report.
1410            let buried = target_dir.join("debug/managed_backends");
1411            smol::fs::create_dir_all(&buried)
1412                .await
1413                .expect("create buried dir");
1414            smol::fs::write(
1415                buried.join(super::METADATA_FILE_NAME),
1416                "project_root = \"x\"",
1417            )
1418            .await
1419            .expect("write buried marker");
1420
1421            let project = tempdir().expect("project dir");
1422            let config = WaterConfig::default();
1423            let managed =
1424                super::ensure_project_build_cache_in(project.path(), cache_root.path(), &config)
1425                    .await
1426                    .expect("ensure managed cache");
1427
1428            let mut discovered = super::discover_managed_build_cache_dirs(cache_root.path())
1429                .await
1430                .expect("discover cache dirs");
1431            let mut expected = vec![managed, target_dir];
1432            discovered.sort();
1433            expected.sort();
1434            assert_eq!(discovered, expected);
1435        });
1436    }
1437
1438    /// Dropping the shared target while a Cargo build holds a profile
1439    /// `.cargo-lock` refuses rather than deleting a live build's tree.
1440    #[test]
1441    fn shared_target_dir_refuses_while_a_build_lock_is_held() {
1442        smol::block_on(async {
1443            let cache_root = tempdir().expect("cache root");
1444            let target_dir = super::ensure_shared_target_dir_in(cache_root.path())
1445                .await
1446                .expect("ensure shared target dir");
1447            let profile = target_dir.join("shared/aarch64-apple-darwin/debug");
1448            smol::fs::create_dir_all(&profile)
1449                .await
1450                .expect("create profile dir");
1451            let lock_file =
1452                std::fs::File::create(profile.join(".cargo-lock")).expect("create cargo lock");
1453            fs4::FileExt::lock(&lock_file).expect("hold the build lock");
1454
1455            let error = super::remove_shared_target_dir_in(cache_root.path())
1456                .await
1457                .expect_err("a held build lock must refuse the drop");
1458            assert!(
1459                error.to_string().contains("in use"),
1460                "the error says why: {error}"
1461            );
1462
1463            fs4::FileExt::unlock(&lock_file).expect("release the build lock");
1464            super::remove_shared_target_dir_in(cache_root.path())
1465                .await
1466                .expect("an unlocked target drops")
1467                .expect("the target existed");
1468        });
1469    }
1470
1471    /// A project clean removes exactly the units Cargo named after this
1472    /// project's generated crates — uplifted outputs, `deps/`, `.fingerprint/`,
1473    /// `build/` and `incremental/` entries, in every variant and profile — and
1474    /// leaves every other project's units and the shared dependency artifacts,
1475    /// the uplifted runtime dylib included, where they are.
1476    #[test]
1477    fn project_clean_removes_only_this_projects_units_from_the_shared_target() {
1478        smol::block_on(async {
1479            let cache_root = tempdir().expect("cache root");
1480            let target_dir = super::ensure_shared_target_dir_in(cache_root.path())
1481                .await
1482                .expect("ensure shared target dir");
1483            let ours = "e2eapp-hydrolysis-1a2b3c4d";
1484            let theirs = "e2eapp-hydrolysis-9f8e7d6c";
1485            let mut kept = Vec::new();
1486            let mut gone = Vec::new();
1487            for profile in [
1488                "shared/x86_64-pc-windows-msvc/debug",
1489                "static/x86_64-pc-windows-msvc/release",
1490                "host/debug",
1491            ] {
1492                let profile = target_dir.join(profile);
1493                for dir in ["deps", ".fingerprint", "build", "incremental"] {
1494                    std::fs::create_dir_all(profile.join(dir)).expect("profile dir");
1495                }
1496                std::fs::write(profile.join(".cargo-lock"), []).expect("cargo lock");
1497                // (relative path, is a unit directory, belongs to `ours`)
1498                let entries = [
1499                    ("e2eapp_hydrolysis_1a2b3c4d.exe", false, true),
1500                    ("e2eapp_hydrolysis_1a2b3c4d.pdb", false, true),
1501                    ("e2eapp_hydrolysis_1a2b3c4d.d", false, true),
1502                    ("e2eapp_hydrolysis_1a2b3c4d_cef_helper.exe", false, true),
1503                    ("deps/e2eapp_hydrolysis_1a2b3c4d-0011.exe", false, true),
1504                    ("deps/libe2eapp_hydrolysis_1a2b3c4d-0022.rlib", false, true),
1505                    ("deps/e2eapp_hydrolysis_1a2b3c4d-0022.d", false, true),
1506                    (".fingerprint/e2eapp-hydrolysis-1a2b3c4d-0011", true, true),
1507                    ("build/e2eapp-hydrolysis-1a2b3c4d-0033", true, true),
1508                    ("incremental/e2eapp_hydrolysis_1a2b3c4d-abc", true, true),
1509                    ("e2eapp_hydrolysis_9f8e7d6c.exe", false, false),
1510                    ("deps/libe2eapp_hydrolysis_9f8e7d6c-0044.rlib", false, false),
1511                    (".fingerprint/e2eapp-hydrolysis-9f8e7d6c-0044", true, false),
1512                    ("waterui_dylib.dll", false, false),
1513                    ("deps/waterui_dylib-0055.dll", false, false),
1514                    ("deps/libwaterui-0066.rlib", false, false),
1515                    (".fingerprint/waterui-dylib-0055", true, false),
1516                    ("build/waterui-chromium-0077", true, false),
1517                ];
1518                for (relative, is_dir, is_ours) in entries {
1519                    let path = profile.join(relative);
1520                    if is_dir {
1521                        std::fs::create_dir_all(path.join("output")).expect("unit dir");
1522                    } else {
1523                        std::fs::write(&path, []).expect("unit file");
1524                    }
1525                    if is_ours { &mut gone } else { &mut kept }.push(path);
1526                }
1527            }
1528            // A directory outside any profile is never inspected.
1529            let metadata = target_dir.join("metadata.toml");
1530            assert!(metadata.is_file(), "the shared target keeps its metadata");
1531
1532            let removed = super::remove_project_units_in(&target_dir, &[ours.to_owned()])
1533                .await
1534                .expect("clean this project's units");
1535
1536            gone.sort();
1537            assert_eq!(removed, gone, "exactly this project's units are reported");
1538            for path in &gone {
1539                assert!(!path.exists(), "{} was removed", path.display());
1540            }
1541            for path in &kept {
1542                assert!(path.exists(), "{} stays", path.display());
1543            }
1544            assert!(metadata.is_file());
1545            assert!(
1546                super::remove_project_units_in(&target_dir, &[theirs.to_owned()])
1547                    .await
1548                    .expect("clean the other project")
1549                    .iter()
1550                    .all(|path| kept.contains(path)),
1551                "the other project's clean removes only what this one kept"
1552            );
1553            assert!(
1554                super::remove_project_units_in(&target_dir, &[ours.to_owned()])
1555                    .await
1556                    .expect("a second clean")
1557                    .is_empty(),
1558                "a second clean finds nothing"
1559            );
1560        });
1561    }
1562
1563    /// A project clean of the shared target refuses while a build holds
1564    /// a profile's `.cargo-lock`, like the full drop does.
1565    #[test]
1566    fn project_clean_of_the_shared_target_refuses_while_a_build_lock_is_held() {
1567        smol::block_on(async {
1568            let cache_root = tempdir().expect("cache root");
1569            let target_dir = super::ensure_shared_target_dir_in(cache_root.path())
1570                .await
1571                .expect("ensure shared target dir");
1572            let profile = target_dir.join("shared/aarch64-apple-darwin/debug");
1573            std::fs::create_dir_all(&profile).expect("profile dir");
1574            std::fs::write(profile.join("demo_hydrolysis_0000abcd"), []).expect("unit");
1575            let lock_file =
1576                std::fs::File::create(profile.join(".cargo-lock")).expect("create cargo lock");
1577            fs4::FileExt::lock(&lock_file).expect("hold the build lock");
1578
1579            let packages = ["demo-hydrolysis-0000abcd".to_owned()];
1580            let error = super::remove_project_units_in(&target_dir, &packages)
1581                .await
1582                .expect_err("a held build lock must refuse the clean");
1583            assert!(error.to_string().contains("in use"), "{error}");
1584            assert!(profile.join("demo_hydrolysis_0000abcd").is_file());
1585
1586            fs4::FileExt::unlock(&lock_file).expect("release the build lock");
1587            let removed = super::remove_project_units_in(&target_dir, &packages)
1588                .await
1589                .expect("an unlocked target cleans");
1590            assert_eq!(removed, [profile.join("demo_hydrolysis_0000abcd")]);
1591        });
1592    }
1593
1594    #[test]
1595    fn unit_entries_belong_to_their_package_only() {
1596        let package = "demo-hydrolysis-0000abcd";
1597        for name in [
1598            "demo-hydrolysis-0000abcd-1234",
1599            "demo_hydrolysis_0000abcd",
1600            "demo_hydrolysis_0000abcd.exe",
1601            "demo_hydrolysis_0000abcd-1234.d",
1602            "libdemo_hydrolysis_0000abcd-1234.rlib",
1603            "demo_hydrolysis_0000abcd_cef_helper.pdb",
1604        ] {
1605            assert!(super::unit_entry_belongs_to(name, package), "{name}");
1606        }
1607        for name in [
1608            "demo-hydrolysis-0000abcd1-1234",
1609            "demo_hydrolysis_0000abce",
1610            "demo_hydrolysis",
1611            "waterui_dylib.dll",
1612            "libdemo-0000.rlib",
1613        ] {
1614            assert!(!super::unit_entry_belongs_to(name, package), "{name}");
1615        }
1616    }
1617
1618    /// The survey reports the shared target, and an explicit drop removes it
1619    /// immediately instead of waiting out the unused-days policy.
1620    #[test]
1621    fn shared_target_dir_can_be_dropped_on_demand() {
1622        smol::block_on(async {
1623            let cache_root = tempdir().expect("cache root");
1624            let target_dir = super::ensure_shared_target_dir_in(cache_root.path())
1625                .await
1626                .expect("ensure shared target dir");
1627            smol::fs::create_dir_all(target_dir.join("debug"))
1628                .await
1629                .expect("create a unit dir");
1630            smol::fs::write(target_dir.join("debug/unit.rlib"), [0u8; 1024])
1631                .await
1632                .expect("write a unit");
1633
1634            let freed = super::remove_shared_target_dir_in(cache_root.path())
1635                .await
1636                .expect("drop the shared target");
1637            assert!(
1638                freed.is_some_and(|bytes| bytes > 0),
1639                "the drop reports the space it held: {freed:?}"
1640            );
1641            assert!(!target_dir.exists());
1642            assert_eq!(
1643                super::remove_shared_target_dir_in(cache_root.path())
1644                    .await
1645                    .expect("a second drop"),
1646                None,
1647                "dropping an absent shared target is a no-op"
1648            );
1649        });
1650    }
1651
1652    #[test]
1653    fn shared_target_dir_is_discovered_and_kept_while_in_use() {
1654        smol::block_on(async {
1655            let project = tempdir().expect("project dir");
1656            let config = WaterConfig::default();
1657            let cache_root = tempdir().expect("cache root");
1658
1659            let target_dir = super::ensure_shared_target_dir_in(cache_root.path())
1660                .await
1661                .expect("ensure shared target dir");
1662
1663            assert_eq!(target_dir, cache_root.path().join("target"));
1664
1665            let discovered = super::discover_managed_build_cache_dirs(cache_root.path())
1666                .await
1667                .expect("discover cache dirs");
1668            assert_eq!(discovered, vec![target_dir.clone()]);
1669
1670            let outcome =
1671                super::cleanup_stale_caches_if_idle(cache_root.path(), project.path(), &config)
1672                    .await
1673                    .expect("cleanup caches");
1674            assert_eq!(
1675                outcome,
1676                super::BuildCacheGcOutcome::Ran(super::BuildCacheGcSummary {
1677                    scanned_entries: 1,
1678                    removed_entries: 0,
1679                })
1680            );
1681            assert!(target_dir.exists());
1682        });
1683    }
1684
1685    #[test]
1686    fn stale_shared_target_dir_is_collected() {
1687        smol::block_on(async {
1688            let project = tempdir().expect("project dir");
1689            let config = WaterConfig::default();
1690            let cache_root = tempdir().expect("cache root");
1691
1692            let target_dir = super::ensure_shared_target_dir_in(cache_root.path())
1693                .await
1694                .expect("ensure shared target dir");
1695            let stale_metadata = super::CacheMetadata {
1696                project_root: target_dir.display().to_string(),
1697                cli_commit: CLI_COMMIT.to_string(),
1698                last_used_unix_seconds: 1,
1699            };
1700            smol::fs::write(
1701                metadata_path(&target_dir),
1702                toml::to_string(&stale_metadata).expect("serialize stale metadata"),
1703            )
1704            .await
1705            .expect("write stale metadata");
1706
1707            let outcome =
1708                super::cleanup_stale_caches_if_idle(cache_root.path(), project.path(), &config)
1709                    .await
1710                    .expect("cleanup caches");
1711            assert_eq!(
1712                outcome,
1713                super::BuildCacheGcOutcome::Ran(super::BuildCacheGcSummary {
1714                    scanned_entries: 1,
1715                    removed_entries: 1,
1716                })
1717            );
1718            assert!(!target_dir.exists());
1719        });
1720    }
1721}