1use 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
21pub 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)]
33pub struct WaterConfig {
35 #[serde(default)]
37 pub build_cache: BuildCacheConfig,
38 #[serde(default)]
43 pub last_used_device: std::collections::BTreeMap<String, String>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct BuildCacheConfig {
49 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct BuildCacheGcSummary {
72 pub scanned_entries: usize,
74 pub removed_entries: usize,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum BuildCacheGcOutcome {
81 Ran(BuildCacheGcSummary),
83 SkippedAlreadyRunning,
85}
86
87const fn default_build_cache_cleanup_after_unused_days() -> u64 {
88 DEFAULT_BUILD_CACHE_CLEANUP_AFTER_UNUSED_DAYS
89}
90
91#[derive(Debug, thiserror::Error)]
93#[error("Could not determine home directory")]
94pub struct HomeDirError;
95
96pub fn water_home_dir() -> Result<PathBuf, HomeDirError> {
101 let home = dirs::home_dir().ok_or(HomeDirError)?;
102 Ok(home.join(".water"))
103}
104
105pub 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
114pub 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
123pub 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
133pub 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
143pub 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
181pub 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
198pub 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(¤t_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
239pub 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
277pub 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#[derive(Debug, Clone, PartialEq, Eq)]
455pub struct BuildCacheEntryUsage {
456 pub project_root: PathBuf,
458 pub cache_dir: PathBuf,
460 pub bytes: u64,
462 pub stale: bool,
464 pub active: bool,
466}
467
468#[derive(Debug, Clone, PartialEq, Eq)]
470pub struct BuildCacheUsageReport {
471 pub entries: Vec<BuildCacheEntryUsage>,
473 pub total_bytes: u64,
475 pub reclaimable_bytes: u64,
477}
478
479pub 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
542async 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 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 drop(lock_file);
664
665 cleanup_result.map(BuildCacheGcOutcome::Ran)
666}
667
668async 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(¤t).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 #[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 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 #[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}