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.
6
7use std::{
8    ffi::OsStr,
9    path::{Component, Path, PathBuf, Prefix, PrefixComponent},
10    process::Stdio,
11    time::{SystemTime, UNIX_EPOCH},
12};
13
14use eyre::WrapErr;
15use fs4::{FileExt, TryLockError};
16use serde::{Deserialize, Serialize};
17use smol::fs;
18use tracing::{info, warn};
19use walkdir::WalkDir;
20
21/// The CLI commit hash embedded at build time.
22pub const CLI_COMMIT: &str = env!("WATERUI_CLI_COMMIT");
23
24const BUILD_CACHE_DIR_NAME: &str = "build_cache";
25const MANAGED_BACKENDS_DIR_NAME: &str = "managed_backends";
26const CONFIG_FILE_NAME: &str = "config.toml";
27const METADATA_FILE_NAME: &str = "metadata.toml";
28const CLEANUP_LOCK_FILE_NAME: &str = ".cleanup.lock";
29const LEGACY_LOCAL_WATER_DIR_NAME: &str = ".water";
30const DEFAULT_BUILD_CACHE_CLEANUP_AFTER_UNUSED_DAYS: u64 = 30;
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
33/// Global Water CLI configuration persisted to `~/.water/config.toml`.
34pub struct WaterConfig {
35    /// Managed build-cache policy.
36    #[serde(default)]
37    pub build_cache: BuildCacheConfig,
38    /// The device `water run` last used per target, keyed by
39    /// `"<backend>/<platform>"` (for example `"apple/ios"`). The value is the
40    /// device's stable identifier — a simulator UDID, a physical-device
41    /// identifier, an Android serial, or an AVD name.
42    #[serde(default)]
43    pub last_used_device: std::collections::BTreeMap<String, String>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47/// Cleanup policy for the global managed build cache.
48pub struct BuildCacheConfig {
49    /// Remove build-cache entries that have been unused for more than this many days.
50    #[serde(default = "default_build_cache_cleanup_after_unused_days")]
51    pub cleanup_after_unused_days: u64,
52}
53
54impl Default for BuildCacheConfig {
55    fn default() -> Self {
56        Self {
57            cleanup_after_unused_days: default_build_cache_cleanup_after_unused_days(),
58        }
59    }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
63struct CacheMetadata {
64    project_root: String,
65    cli_commit: String,
66    last_used_unix_seconds: u64,
67}
68
69/// Summary of one managed build-cache garbage-collection pass.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct BuildCacheGcSummary {
72    /// Number of managed cache entries inspected, excluding the active project cache.
73    pub scanned_entries: usize,
74    /// Number of stale managed cache entries removed during this pass.
75    pub removed_entries: usize,
76}
77
78/// Result of attempting to garbage-collect stale managed build-cache entries.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum BuildCacheGcOutcome {
81    /// Cleanup ran to completion and produced a removal summary.
82    Ran(BuildCacheGcSummary),
83    /// Cleanup did not run because another `water gc build-cache` process already holds the lock.
84    SkippedAlreadyRunning,
85}
86
87const fn default_build_cache_cleanup_after_unused_days() -> u64 {
88    DEFAULT_BUILD_CACHE_CLEANUP_AFTER_UNUSED_DAYS
89}
90
91/// The current user's home directory could not be determined.
92#[derive(Debug, thiserror::Error)]
93#[error("Could not determine home directory")]
94pub struct HomeDirError;
95
96/// Return the Water home directory root at `~/.water`.
97///
98/// # Errors
99/// Returns an error if the current user's home directory cannot be determined.
100pub fn water_home_dir() -> Result<PathBuf, HomeDirError> {
101    let home = dirs::home_dir().ok_or(HomeDirError)?;
102    Ok(home.join(".water"))
103}
104
105/// Return the Water home directory root at `~/.water` for `host`.
106///
107/// # Errors
108/// Returns an error if the host's home directory cannot be determined.
109pub fn water_home_dir_in(host: &crate::toolchain::Host) -> Result<PathBuf, HomeDirError> {
110    let home = host.home_dir().ok_or(HomeDirError)?;
111    Ok(home.join(".water"))
112}
113
114/// Ensure `~/.water/config.toml` exists and return the parsed configuration.
115///
116/// # Errors
117/// Returns an error if the Water home cannot be created or the config cannot be read or written.
118pub async fn ensure_global_config() -> eyre::Result<WaterConfig> {
119    let water_home = water_home_dir()?;
120    ensure_global_config_in(&water_home).await
121}
122
123/// Return the global managed build-cache root at `~/.water/build_cache`.
124///
125/// # Errors
126/// Returns an error if the Water config cannot be loaded or the cache root cannot be created.
127pub async fn build_cache_root() -> eyre::Result<PathBuf> {
128    let water_home = water_home_dir()?;
129    let (_, cache_root) = resolved_build_cache_root_in(&water_home).await?;
130    Ok(cache_root)
131}
132
133/// Return the managed build-cache directory for a project.
134///
135/// # Errors
136/// Returns an error if the project root cannot be canonicalized or the global cache root cannot be resolved.
137pub async fn project_build_cache_dir(project_root: &Path) -> eyre::Result<PathBuf> {
138    let project_root = canonicalize_project_root(project_root)?;
139    let cache_root = build_cache_root().await?;
140    Ok(project_build_cache_dir_in(&project_root, &cache_root))
141}
142
143/// Return the whole managed cache container for a project directory, which need
144/// not exist.
145///
146/// A generated backend workspace lives in the cache rather than in the project,
147/// so a project that has been thrown away leaves its cache behind — and that
148/// cache is what the next build reads. Canonicalizing the project root is not
149/// available in that case, so the nearest existing ancestor is canonicalized
150/// and the rest of the path appended as written.
151///
152/// # Errors
153/// Returns an error if no ancestor of `project_root` can be canonicalized or the
154/// global cache root cannot be resolved.
155pub async fn build_cache_container_for(project_root: &Path) -> eyre::Result<PathBuf> {
156    let mut trailing = Vec::new();
157    let mut existing = project_root.to_path_buf();
158    let resolved = loop {
159        if let Ok(resolved) = existing.canonicalize() {
160            break resolved;
161        }
162        let name = existing.file_name().map(std::ffi::OsString::from);
163        let parent = existing.parent().map(Path::to_path_buf);
164        let (Some(name), Some(parent)) = (name, parent) else {
165            return Err(eyre::eyre!(
166                "Failed to resolve any existing ancestor of {}",
167                project_root.display()
168            ));
169        };
170        trailing.push(name);
171        existing = parent;
172    };
173    let mut project_root = resolved;
174    for name in trailing.iter().rev() {
175        project_root.push(name);
176    }
177    let cache_root = build_cache_root().await?;
178    Ok(project_cache_container_in(&project_root, &cache_root))
179}
180
181/// Ensure the managed build-cache directory exists for a project and return it.
182///
183/// # Errors
184/// Returns an error if the project root cannot be canonicalized, config loading fails, or cache directories cannot be created.
185pub async fn ensure_project_build_cache(project_root: &Path) -> eyre::Result<PathBuf> {
186    let project_root = canonicalize_project_root(project_root)?;
187    let water_home = water_home_dir()?;
188    let (config, cache_root) = resolved_build_cache_root_in(&water_home).await?;
189    if let Err(error) = spawn_build_cache_cleanup_process(&project_root).await {
190        warn!(
191            current_project_root = %project_root.display(),
192            "Failed to spawn build-cache cleanup process: {error}"
193        );
194    }
195    ensure_project_build_cache_in(&project_root, &cache_root, &config).await
196}
197
198/// Garbage-collect stale managed build-cache entries while preserving the current project's cache.
199///
200/// # Errors
201/// Returns an error if the project root cannot be canonicalized, config loading fails,
202/// or stale cache removal fails.
203pub async fn cleanup_stale_build_caches_for_project(
204    project_root: &Path,
205) -> eyre::Result<BuildCacheGcOutcome> {
206    let project_root = canonicalize_project_root(project_root)?;
207    let water_home = water_home_dir()?;
208    let (config, cache_root) = resolved_build_cache_root_in(&water_home).await?;
209    cleanup_stale_caches_if_idle(&cache_root, &project_root, &config).await
210}
211
212async fn spawn_build_cache_cleanup_process(project_root: &Path) -> eyre::Result<()> {
213    let current_executable = std::env::current_exe()
214        .wrap_err("Failed to resolve current water executable for build-cache cleanup")?;
215    let project_root = project_root.to_path_buf();
216
217    smol::unblock(move || -> eyre::Result<()> {
218        std::process::Command::new(&current_executable)
219            .arg("gc")
220            .arg("build-cache")
221            .arg("--path")
222            .arg(&project_root)
223            .stdin(Stdio::null())
224            .stdout(Stdio::null())
225            .stderr(Stdio::null())
226            .spawn()
227            .map(|_| ())
228            .map_err(eyre::Report::from)
229            .wrap_err_with(|| {
230                format!(
231                    "Failed to spawn build-cache cleanup process for {}",
232                    project_root.display()
233                )
234            })
235    })
236    .await
237}
238
239/// Remove the managed build cache for a project.
240///
241/// # Errors
242/// Returns an error if the project root cannot be canonicalized or cache entries cannot be removed.
243pub async fn remove_project_build_cache(project_root: &Path) -> eyre::Result<()> {
244    let project_root = canonicalize_project_root(project_root)?;
245    let cache_root = build_cache_root().await?;
246    remove_project_build_cache_in(&project_root, &cache_root).await
247}
248
249async fn resolved_build_cache_root_in(water_home: &Path) -> eyre::Result<(WaterConfig, PathBuf)> {
250    let config = ensure_global_config_in(water_home).await?;
251    let cache_root = water_home.join(BUILD_CACHE_DIR_NAME);
252    fs::create_dir_all(&cache_root)
253        .await
254        .wrap_err_with(|| format!("Failed to create build cache root {}", cache_root.display()))?;
255    Ok((config, cache_root))
256}
257
258async fn ensure_global_config_in(water_home: &Path) -> eyre::Result<WaterConfig> {
259    fs::create_dir_all(water_home)
260        .await
261        .wrap_err_with(|| format!("Failed to create Water home {}", water_home.display()))?;
262
263    let config_path = water_home.join(CONFIG_FILE_NAME);
264    if config_path.exists() {
265        let contents = fs::read_to_string(&config_path)
266            .await
267            .wrap_err_with(|| format!("Failed to read Water config {}", config_path.display()))?;
268        return toml::from_str(&contents)
269            .wrap_err_with(|| format!("Failed to parse Water config {}", config_path.display()));
270    }
271
272    let config = WaterConfig::default();
273    write_global_config_in(water_home, &config).await?;
274    Ok(config)
275}
276
277/// Persist `config` to `~/.water/config.toml`.
278///
279/// # Errors
280/// Returns an error if the config cannot be serialized or written.
281pub async fn write_global_config(config: &WaterConfig) -> eyre::Result<()> {
282    let water_home = water_home_dir()?;
283    write_global_config_in(&water_home, config).await
284}
285
286async fn write_global_config_in(water_home: &Path, config: &WaterConfig) -> eyre::Result<()> {
287    let config_path = water_home.join(CONFIG_FILE_NAME);
288    let contents = toml::to_string_pretty(config).wrap_err("Failed to serialize Water config")?;
289    fs::write(&config_path, contents)
290        .await
291        .wrap_err_with(|| format!("Failed to write Water config {}", config_path.display()))
292}
293
294async fn ensure_project_build_cache_in(
295    project_root: &Path,
296    cache_root: &Path,
297    _config: &WaterConfig,
298) -> eyre::Result<PathBuf> {
299    remove_legacy_local_water_dir(project_root).await?;
300
301    let cache_dir = project_build_cache_dir_in(project_root, cache_root);
302    if cache_dir.exists() {
303        let should_clean = match read_metadata(&cache_dir).await {
304            Ok(metadata) => {
305                metadata.project_root != project_root.display().to_string()
306                    || metadata.cli_commit != CLI_COMMIT
307            }
308            Err(_) => true,
309        };
310
311        if should_clean {
312            info!(
313                "Managed build cache changed shape, cleaning {}",
314                cache_dir.display()
315            );
316            fs::remove_dir_all(&cache_dir).await?;
317            prune_empty_build_cache_ancestors(
318                cache_root,
319                cache_dir
320                    .parent()
321                    .expect("managed build cache dir should always have a parent"),
322            )
323            .await?;
324        }
325    }
326
327    fs::create_dir_all(&cache_dir)
328        .await
329        .wrap_err_with(|| format!("Failed to create build cache dir {}", cache_dir.display()))?;
330    write_metadata(
331        &cache_dir,
332        &CacheMetadata {
333            project_root: project_root.display().to_string(),
334            cli_commit: CLI_COMMIT.to_string(),
335            last_used_unix_seconds: now_unix_seconds()?,
336        },
337    )
338    .await?;
339
340    Ok(cache_dir)
341}
342
343async fn remove_project_build_cache_in(project_root: &Path, cache_root: &Path) -> eyre::Result<()> {
344    let cache_dir = project_build_cache_dir_in(project_root, cache_root);
345    if cache_dir.exists() {
346        fs::remove_dir_all(&cache_dir).await?;
347        prune_empty_build_cache_ancestors(
348            cache_root,
349            cache_dir
350                .parent()
351                .expect("managed build cache dir should always have a parent"),
352        )
353        .await?;
354    }
355    remove_legacy_local_water_dir(project_root).await
356}
357
358fn project_build_cache_dir_in(project_root: &Path, cache_root: &Path) -> PathBuf {
359    project_cache_container_in(project_root, cache_root).join(MANAGED_BACKENDS_DIR_NAME)
360}
361
362fn project_cache_container_in(project_root: &Path, cache_root: &Path) -> PathBuf {
363    let mut path = cache_root.to_path_buf();
364    for component in project_root.components() {
365        match component {
366            Component::Prefix(prefix) => path.push(normalize_prefix_component(prefix)),
367            Component::RootDir => {}
368            Component::Normal(segment) => path.push(segment),
369            Component::CurDir | Component::ParentDir => {
370                panic!(
371                    "Canonical project root {} must not contain relative path components",
372                    project_root.display()
373                );
374            }
375        }
376    }
377    path
378}
379
380fn normalize_prefix_component(prefix: PrefixComponent<'_>) -> String {
381    match prefix.kind() {
382        Prefix::Disk(letter) | Prefix::VerbatimDisk(letter) => {
383            format!("drive-{}", char::from(letter).to_ascii_uppercase())
384        }
385        Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {
386            format!("unc-{}-{}", sanitize_os_str(server), sanitize_os_str(share))
387        }
388        Prefix::DeviceNS(device) => format!("device-{}", sanitize_os_str(device)),
389        Prefix::Verbatim(component) => format!("verbatim-{}", sanitize_os_str(component)),
390    }
391}
392
393fn sanitize_os_str(value: &OsStr) -> String {
394    let sanitized = value
395        .to_string_lossy()
396        .chars()
397        .map(|character| {
398            if character.is_ascii_alphanumeric() {
399                character
400            } else {
401                '_'
402            }
403        })
404        .collect::<String>();
405    if sanitized.is_empty() {
406        return String::from("empty");
407    }
408    sanitized
409}
410
411fn canonicalize_project_root(project_root: &Path) -> eyre::Result<PathBuf> {
412    project_root.canonicalize().wrap_err_with(|| {
413        format!(
414            "Failed to canonicalize project root {}",
415            project_root.display()
416        )
417    })
418}
419
420fn metadata_path(cache_dir: &Path) -> PathBuf {
421    cache_dir.join(METADATA_FILE_NAME)
422}
423
424async fn read_metadata(cache_dir: &Path) -> eyre::Result<CacheMetadata> {
425    let metadata_path = metadata_path(cache_dir);
426    let contents = fs::read_to_string(&metadata_path)
427        .await
428        .wrap_err_with(|| format!("Failed to read cache metadata {}", metadata_path.display()))?;
429    toml::from_str(&contents)
430        .wrap_err_with(|| format!("Failed to parse cache metadata {}", metadata_path.display()))
431}
432
433async fn write_metadata(cache_dir: &Path, metadata: &CacheMetadata) -> eyre::Result<()> {
434    let metadata_path = metadata_path(cache_dir);
435    let contents = toml::to_string(metadata).wrap_err("Failed to serialize cache metadata")?;
436    fs::write(&metadata_path, contents)
437        .await
438        .wrap_err_with(|| format!("Failed to write cache metadata {}", metadata_path.display()))
439}
440
441async fn remove_legacy_local_water_dir(project_root: &Path) -> eyre::Result<()> {
442    let legacy_dir = project_root.join(LEGACY_LOCAL_WATER_DIR_NAME);
443    if legacy_dir.exists() {
444        info!(
445            "Removing legacy local playground cache at {}",
446            legacy_dir.display()
447        );
448        fs::remove_dir_all(&legacy_dir).await?;
449    }
450    Ok(())
451}
452
453/// On-disk usage of one managed build-cache entry.
454#[derive(Debug, Clone, PartialEq, Eq)]
455pub struct BuildCacheEntryUsage {
456    /// Project the cache entry belongs to.
457    pub project_root: PathBuf,
458    /// Managed cache directory holding the entry.
459    pub cache_dir: PathBuf,
460    /// Disk space the entry occupies, in bytes.
461    pub bytes: u64,
462    /// Whether the entry is currently eligible for removal.
463    pub stale: bool,
464    /// Whether the entry belongs to the project the survey was run from.
465    pub active: bool,
466}
467
468/// What the managed build cache is currently spending disk on.
469#[derive(Debug, Clone, PartialEq, Eq)]
470pub struct BuildCacheUsageReport {
471    /// Entries, largest first.
472    pub entries: Vec<BuildCacheEntryUsage>,
473    /// Total disk space across every entry, in bytes.
474    pub total_bytes: u64,
475    /// Disk space held by entries eligible for removal, in bytes.
476    pub reclaimable_bytes: u64,
477}
478
479/// Survey the managed build cache without removing anything.
480///
481/// Sizes are measured in allocated blocks and each inode is counted once, so
482/// copy-on-write clones and hard links are reported at what they actually cost
483/// rather than at the sum of their apparent lengths.
484///
485/// # Errors
486/// Returns an error if the cache root cannot be read or the Water config cannot be loaded.
487pub async fn survey_build_cache_usage(
488    current_project_root: &Path,
489) -> eyre::Result<BuildCacheUsageReport> {
490    let water_home = water_home_dir()?;
491    let (config, cache_root) = resolved_build_cache_root_in(&water_home).await?;
492    let current_cache_dir = project_build_cache_dir_in(current_project_root, &cache_root);
493    let max_unused_seconds = config
494        .build_cache
495        .cleanup_after_unused_days
496        .saturating_mul(24 * 60 * 60);
497    let now = now_unix_seconds()?;
498
499    let mut entries = Vec::new();
500    for cache_dir in discover_managed_build_cache_dirs(&cache_root).await? {
501        let metadata = read_metadata(&cache_dir).await.ok();
502        let project_root = metadata.as_ref().map_or_else(
503            || cache_dir.clone(),
504            |metadata| PathBuf::from(&metadata.project_root),
505        );
506        let active = cache_dir == current_cache_dir;
507        let stale = !active
508            && metadata.as_ref().is_none_or(|metadata| {
509                !PathBuf::from(&metadata.project_root).exists()
510                    || now.saturating_sub(metadata.last_used_unix_seconds) > max_unused_seconds
511            });
512        let bytes = directory_disk_usage(cache_dir.clone()).await?;
513        entries.push(BuildCacheEntryUsage {
514            project_root,
515            cache_dir,
516            bytes,
517            stale,
518            active,
519        });
520    }
521
522    entries.sort_by(|left, right| {
523        right
524            .bytes
525            .cmp(&left.bytes)
526            .then_with(|| left.cache_dir.cmp(&right.cache_dir))
527    });
528    let total_bytes = entries.iter().map(|entry| entry.bytes).sum();
529    let reclaimable_bytes = entries
530        .iter()
531        .filter(|entry| entry.stale)
532        .map(|entry| entry.bytes)
533        .sum();
534
535    Ok(BuildCacheUsageReport {
536        entries,
537        total_bytes,
538        reclaimable_bytes,
539    })
540}
541
542/// Measure how much disk a directory tree actually occupies.
543async fn directory_disk_usage(root: PathBuf) -> eyre::Result<u64> {
544    smol::unblock(move || {
545        let mut seen_inodes = std::collections::HashSet::new();
546        let mut total = 0u64;
547        for entry in WalkDir::new(&root).follow_links(false) {
548            let Ok(entry) = entry else { continue };
549            if !entry.file_type().is_file() {
550                continue;
551            }
552            let Ok(metadata) = entry.metadata() else {
553                continue;
554            };
555            total = total.saturating_add(file_disk_usage(&metadata, &mut seen_inodes));
556        }
557        Ok(total)
558    })
559    .await
560}
561
562#[cfg(unix)]
563fn file_disk_usage(
564    metadata: &std::fs::Metadata,
565    seen_inodes: &mut std::collections::HashSet<(u64, u64)>,
566) -> u64 {
567    use std::os::unix::fs::MetadataExt as _;
568
569    if !seen_inodes.insert((metadata.dev(), metadata.ino())) {
570        return 0;
571    }
572    // `blocks` is always in 512-byte units, independent of the filesystem's own
573    // block size, and already excludes extents shared with a clone source.
574    metadata.blocks().saturating_mul(512)
575}
576
577#[cfg(not(unix))]
578fn file_disk_usage(
579    metadata: &std::fs::Metadata,
580    _seen_inodes: &mut std::collections::HashSet<(u64, u64)>,
581) -> u64 {
582    metadata.len()
583}
584
585async fn cleanup_stale_caches(
586    cache_root: &Path,
587    current_project_root: &Path,
588    config: &WaterConfig,
589) -> eyre::Result<BuildCacheGcSummary> {
590    fs::create_dir_all(cache_root).await?;
591
592    let current_cache_dir = project_build_cache_dir_in(current_project_root, cache_root);
593    let max_unused_seconds = config
594        .build_cache
595        .cleanup_after_unused_days
596        .saturating_mul(24 * 60 * 60);
597    let now = now_unix_seconds()?;
598
599    let mut scanned_entries = 0usize;
600    let mut removed_entries = 0usize;
601
602    for cache_dir in discover_managed_build_cache_dirs(cache_root).await? {
603        if cache_dir == current_cache_dir {
604            continue;
605        }
606
607        scanned_entries += 1;
608
609        let should_remove = match read_metadata(&cache_dir).await {
610            Ok(metadata) => {
611                let project_root = PathBuf::from(&metadata.project_root);
612                !project_root.exists()
613                    || now.saturating_sub(metadata.last_used_unix_seconds) > max_unused_seconds
614            }
615            Err(error) => {
616                warn!(
617                    "Removing stale build cache with invalid metadata at {}: {error}",
618                    cache_dir.display()
619                );
620                true
621            }
622        };
623
624        if should_remove {
625            if let Err(error) = fs::remove_dir_all(&cache_dir).await {
626                warn!(
627                    "Failed to remove stale build cache {}: {error}",
628                    cache_dir.display()
629                );
630                continue;
631            }
632            prune_empty_build_cache_ancestors(
633                cache_root,
634                cache_dir
635                    .parent()
636                    .expect("managed build cache dir should always have a parent"),
637            )
638            .await?;
639            removed_entries += 1;
640        }
641    }
642
643    Ok(BuildCacheGcSummary {
644        scanned_entries,
645        removed_entries,
646    })
647}
648
649async fn cleanup_stale_caches_if_idle(
650    cache_root: &Path,
651    current_project_root: &Path,
652    config: &WaterConfig,
653) -> eyre::Result<BuildCacheGcOutcome> {
654    let lock_path = cache_root.join(CLEANUP_LOCK_FILE_NAME);
655    let Some(lock_file) = try_acquire_cleanup_lock(&lock_path).await? else {
656        return Ok(BuildCacheGcOutcome::SkippedAlreadyRunning);
657    };
658
659    let cleanup_result = cleanup_stale_caches(cache_root, current_project_root, config).await;
660    // Closing the descriptor releases the lock. The file stays behind on
661    // purpose: it is what the next sweep locks, and leaving it means no exit
662    // path — including one that never runs — can stop cleanup happening again.
663    drop(lock_file);
664
665    cleanup_result.map(BuildCacheGcOutcome::Ran)
666}
667
668/// Takes the cleanup lock, or reports that another sweep already holds it.
669///
670/// The lock has to be released even when the process holding it dies, and
671/// creating the file exclusively is not that: a sweep that is killed leaves the
672/// file behind, and since then every run takes the "already running" path
673/// against a process that no longer exists. Cleanup is spawned detached with its
674/// output discarded, so nothing says so — one interrupted sweep in May left the
675/// cache unswept until August, by which point it had grown to 149 GB, most of it
676/// belonging to workspaces deleted months earlier.
677///
678/// `flock` is released by the kernel when the descriptor closes, which a crash
679/// does too. The file itself stays: it is the thing being locked, not the
680/// signal, so nothing has to remove it and nothing is stranded if a process
681/// dies before it can.
682async fn try_acquire_cleanup_lock(lock_path: &Path) -> eyre::Result<Option<std::fs::File>> {
683    let lock_path = lock_path.to_path_buf();
684    smol::unblock(move || {
685        let file = std::fs::OpenOptions::new()
686            .write(true)
687            .create(true)
688            .truncate(false)
689            .open(&lock_path)
690            .wrap_err_with(|| format!("Failed to open cleanup lock {}", lock_path.display()))?;
691        match FileExt::try_lock(&file) {
692            Ok(()) => Ok(Some(file)),
693            Err(TryLockError::WouldBlock) => Ok(None),
694            Err(TryLockError::Error(error)) => Err(eyre::Report::from(error))
695                .wrap_err_with(|| format!("Failed to lock {}", lock_path.display())),
696        }
697    })
698    .await
699}
700
701async fn discover_managed_build_cache_dirs(cache_root: &Path) -> eyre::Result<Vec<PathBuf>> {
702    let cache_root = cache_root.to_path_buf();
703    smol::unblock(move || -> eyre::Result<Vec<PathBuf>> {
704        let mut cache_dirs = Vec::new();
705        for entry in WalkDir::new(&cache_root).follow_links(false) {
706            let entry = entry.map_err(eyre::Report::from)?;
707            if !entry.file_type().is_file() || entry.file_name() != OsStr::new(METADATA_FILE_NAME) {
708                continue;
709            }
710
711            let cache_dir = entry.path().parent().ok_or_else(|| {
712                eyre::eyre!("Cache metadata {} has no parent", entry.path().display())
713            })?;
714            if cache_dir.file_name() != Some(OsStr::new(MANAGED_BACKENDS_DIR_NAME)) {
715                continue;
716            }
717            cache_dirs.push(cache_dir.to_path_buf());
718        }
719        Ok(cache_dirs)
720    })
721    .await
722}
723
724async fn prune_empty_build_cache_ancestors(
725    cache_root: &Path,
726    starting_dir: &Path,
727) -> eyre::Result<()> {
728    let mut current = starting_dir.to_path_buf();
729    while current.starts_with(cache_root) && current != cache_root {
730        match fs::remove_dir(&current).await {
731            Ok(()) => {
732                let Some(parent) = current.parent() else {
733                    break;
734                };
735                current = parent.to_path_buf();
736            }
737            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
738                let Some(parent) = current.parent() else {
739                    break;
740                };
741                current = parent.to_path_buf();
742            }
743            Err(error) if error.kind() == std::io::ErrorKind::DirectoryNotEmpty => break,
744            Err(error) => return Err(error.into()),
745        }
746    }
747    Ok(())
748}
749
750fn now_unix_seconds() -> eyre::Result<u64> {
751    Ok(SystemTime::now()
752        .duration_since(UNIX_EPOCH)
753        .wrap_err("System clock is before UNIX_EPOCH")?
754        .as_secs())
755}
756
757#[cfg(test)]
758mod tests {
759    use std::path::{Path, PathBuf};
760
761    use tempfile::tempdir;
762
763    use super::{
764        CLI_COMMIT, WaterConfig, ensure_global_config_in, ensure_project_build_cache_in,
765        metadata_path, now_unix_seconds, project_build_cache_dir_in, remove_project_build_cache_in,
766        write_global_config_in,
767    };
768
769    #[test]
770    fn global_config_round_trips_last_used_devices() {
771        smol::block_on(async {
772            let water_home = tempdir().expect("water home");
773
774            let mut config = WaterConfig::default();
775            config.last_used_device.insert(
776                "apple/ios".to_owned(),
777                "00008140-00011C210CF3001C".to_owned(),
778            );
779            config
780                .last_used_device
781                .insert("android/android".to_owned(), "emulator-5554".to_owned());
782            write_global_config_in(water_home.path(), &config)
783                .await
784                .expect("write config");
785
786            let loaded = ensure_global_config_in(water_home.path())
787                .await
788                .expect("read config back");
789            assert_eq!(
790                loaded.last_used_device.get("apple/ios").map(String::as_str),
791                Some("00008140-00011C210CF3001C")
792            );
793            assert_eq!(
794                loaded
795                    .last_used_device
796                    .get("android/android")
797                    .map(String::as_str),
798                Some("emulator-5554")
799            );
800        });
801    }
802
803    #[test]
804    fn ensure_global_config_writes_default_build_cache_policy() {
805        smol::block_on(async {
806            let water_home = tempdir().expect("water home");
807
808            let config = ensure_global_config_in(water_home.path())
809                .await
810                .expect("ensure config");
811
812            assert_eq!(
813                config.build_cache.cleanup_after_unused_days,
814                super::DEFAULT_BUILD_CACHE_CLEANUP_AFTER_UNUSED_DAYS
815            );
816            let saved = smol::fs::read_to_string(water_home.path().join("config.toml"))
817                .await
818                .expect("read config");
819            assert!(saved.contains("[build_cache]"));
820            assert!(saved.contains("cleanup_after_unused_days = 30"));
821        });
822    }
823
824    #[test]
825    fn project_build_cache_dir_uses_absolute_project_path_components() {
826        let cache_root = Path::new("/tmp/water-cache-root");
827        let project_root = if cfg!(windows) {
828            PathBuf::from(r"C:\Users\lexo\demo")
829        } else {
830            PathBuf::from("/Users/lexo/demo")
831        };
832
833        let cache_dir = project_build_cache_dir_in(&project_root, cache_root);
834
835        let expected = if cfg!(windows) {
836            cache_root.join("drive-C/Users/lexo/demo/managed_backends")
837        } else {
838            cache_root.join("Users/lexo/demo/managed_backends")
839        };
840        assert_eq!(cache_dir, expected);
841    }
842
843    #[test]
844    fn ensure_project_build_cache_uses_global_build_cache_dir() {
845        smol::block_on(async {
846            let project = tempdir().expect("project dir");
847            let water_home = tempdir().expect("water home");
848            let config = ensure_global_config_in(water_home.path())
849                .await
850                .expect("ensure config");
851            let cache_root = water_home.path().join("build_cache");
852
853            let cache_dir = ensure_project_build_cache_in(project.path(), &cache_root, &config)
854                .await
855                .expect("ensure cache");
856
857            assert!(cache_dir.starts_with(&cache_root));
858            assert_ne!(cache_dir, project.path().join(".water"));
859            assert!(cache_dir.ends_with("managed_backends"));
860            assert!(cache_dir.exists());
861        });
862    }
863
864    #[test]
865    fn ensure_project_build_cache_removes_legacy_local_water_dir() {
866        smol::block_on(async {
867            let project = tempdir().expect("project dir");
868            let water_home = tempdir().expect("water home");
869            let config = ensure_global_config_in(water_home.path())
870                .await
871                .expect("ensure config");
872            let cache_root = water_home.path().join("build_cache");
873            let legacy_dir = project.path().join(".water");
874            smol::fs::create_dir_all(&legacy_dir)
875                .await
876                .expect("create legacy dir");
877            smol::fs::write(legacy_dir.join("stale"), b"stale")
878                .await
879                .expect("write legacy file");
880
881            ensure_project_build_cache_in(project.path(), &cache_root, &config)
882                .await
883                .expect("ensure cache");
884
885            assert!(!legacy_dir.exists());
886        });
887    }
888
889    #[test]
890    fn ensure_project_build_cache_cleans_cache_when_cli_commit_changes() {
891        smol::block_on(async {
892            let project = tempdir().expect("project dir");
893            let water_home = tempdir().expect("water home");
894            let config = ensure_global_config_in(water_home.path())
895                .await
896                .expect("ensure config");
897            let cache_root = water_home.path().join("build_cache");
898            let cache_dir = project_build_cache_dir_in(project.path(), &cache_root);
899            smol::fs::create_dir_all(&cache_dir)
900                .await
901                .expect("create cache dir");
902            smol::fs::write(cache_dir.join("stale"), b"stale")
903                .await
904                .expect("write stale file");
905            let stale_metadata = super::CacheMetadata {
906                project_root: project.path().display().to_string(),
907                cli_commit: String::from("old-commit"),
908                last_used_unix_seconds: 1,
909            };
910            let stale_contents =
911                toml::to_string(&stale_metadata).expect("serialize stale metadata");
912            smol::fs::write(metadata_path(&cache_dir), stale_contents)
913                .await
914                .expect("write stale metadata");
915
916            ensure_project_build_cache_in(project.path(), &cache_root, &config)
917                .await
918                .expect("ensure cache");
919
920            assert!(!cache_dir.join("stale").exists());
921            let fresh_metadata = super::read_metadata(&cache_dir)
922                .await
923                .expect("read metadata");
924            assert_eq!(fresh_metadata.cli_commit, CLI_COMMIT);
925        });
926    }
927
928    #[test]
929    fn cleanup_stale_caches_removes_stale_orphaned_caches() {
930        smol::block_on(async {
931            let project = tempdir().expect("project dir");
932            let water_home = tempdir().expect("water home");
933            let config = WaterConfig::default();
934            let cache_root = water_home.path().join("build_cache");
935            let stale_cache = cache_root.join("definitely/missing/project/managed_backends");
936            smol::fs::create_dir_all(&stale_cache)
937                .await
938                .expect("create stale cache");
939            let stale_metadata = super::CacheMetadata {
940                project_root: Path::new("/definitely/missing/project")
941                    .display()
942                    .to_string(),
943                cli_commit: CLI_COMMIT.to_string(),
944                last_used_unix_seconds: now_unix_seconds().expect("now"),
945            };
946            let stale_contents =
947                toml::to_string(&stale_metadata).expect("serialize stale metadata");
948            smol::fs::write(metadata_path(&stale_cache), stale_contents)
949                .await
950                .expect("write stale metadata");
951
952            let outcome = super::cleanup_stale_caches_if_idle(&cache_root, project.path(), &config)
953                .await
954                .expect("cleanup caches");
955
956            assert_eq!(
957                outcome,
958                super::BuildCacheGcOutcome::Ran(super::BuildCacheGcSummary {
959                    scanned_entries: 1,
960                    removed_entries: 1,
961                })
962            );
963            assert!(!stale_cache.exists());
964        });
965    }
966
967    #[test]
968    fn remove_project_build_cache_deletes_only_managed_backends_leaf() {
969        smol::block_on(async {
970            let project = tempdir().expect("project dir");
971            let child_project = project.path().join("nested/child");
972            smol::fs::create_dir_all(&child_project)
973                .await
974                .expect("create child project");
975            let water_home = tempdir().expect("water home");
976            let config = ensure_global_config_in(water_home.path())
977                .await
978                .expect("ensure config");
979            let cache_root = water_home.path().join("build_cache");
980
981            let parent_cache = ensure_project_build_cache_in(project.path(), &cache_root, &config)
982                .await
983                .expect("ensure parent cache");
984            let child_cache = ensure_project_build_cache_in(&child_project, &cache_root, &config)
985                .await
986                .expect("ensure child cache");
987
988            remove_project_build_cache_in(project.path(), &cache_root)
989                .await
990                .expect("remove parent cache");
991
992            assert!(!parent_cache.exists());
993            assert!(child_cache.exists());
994        });
995    }
996
997    /// A sweep that dies leaves the lock file behind, because only the happy
998    /// path could ever remove it. Cleanup must still run afterwards: when it did
999    /// not, one interrupted sweep stopped every later one for three months, and
1000    /// silently, since cleanup is spawned with its output discarded.
1001    #[test]
1002    fn cleanup_runs_again_after_a_sweep_dies_holding_the_lock() {
1003        smol::block_on(async {
1004            let project = tempdir().expect("project dir");
1005            let water_home = tempdir().expect("water home");
1006            let config = ensure_global_config_in(water_home.path())
1007                .await
1008                .expect("ensure config");
1009            let cache_root = water_home.path().join("build_cache");
1010            smol::fs::create_dir_all(&cache_root)
1011                .await
1012                .expect("create cache root");
1013
1014            // What a killed sweep leaves on disk: the lock file, with no live
1015            // process behind it.
1016            let lock_path = cache_root.join(super::CLEANUP_LOCK_FILE_NAME);
1017            smol::fs::write(&lock_path, [])
1018                .await
1019                .expect("leave a lock file behind");
1020
1021            let stale_project = tempdir().expect("stale project");
1022            let stale_cache =
1023                ensure_project_build_cache_in(stale_project.path(), &cache_root, &config)
1024                    .await
1025                    .expect("ensure stale cache");
1026            drop(stale_project);
1027
1028            let outcome = super::cleanup_stale_caches_if_idle(&cache_root, project.path(), &config)
1029                .await
1030                .expect("cleanup");
1031
1032            assert!(
1033                matches!(outcome, super::BuildCacheGcOutcome::Ran(_)),
1034                "an abandoned lock file must not be read as a running sweep, got {outcome:?}"
1035            );
1036            assert!(!stale_cache.exists());
1037        });
1038    }
1039
1040    /// The other half: while a sweep really is holding the lock, a second one
1041    /// stands down instead of walking the same tree.
1042    #[test]
1043    fn a_second_sweep_stands_down_while_the_first_holds_the_lock() {
1044        smol::block_on(async {
1045            let project = tempdir().expect("project dir");
1046            let water_home = tempdir().expect("water home");
1047            let config = ensure_global_config_in(water_home.path())
1048                .await
1049                .expect("ensure config");
1050            let cache_root = water_home.path().join("build_cache");
1051            smol::fs::create_dir_all(&cache_root)
1052                .await
1053                .expect("create cache root");
1054
1055            let lock_path = cache_root.join(super::CLEANUP_LOCK_FILE_NAME);
1056            let held = super::try_acquire_cleanup_lock(&lock_path)
1057                .await
1058                .expect("acquire lock")
1059                .expect("lock is free");
1060
1061            let outcome = super::cleanup_stale_caches_if_idle(&cache_root, project.path(), &config)
1062                .await
1063                .expect("cleanup");
1064            assert_eq!(outcome, super::BuildCacheGcOutcome::SkippedAlreadyRunning);
1065
1066            drop(held);
1067        });
1068    }
1069}