use std::fs;
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use serde::{Deserialize, Serialize};
use super::error::WorkspaceError;
use super::serde_time;
pub const WORKSPACE_CACHE_DIRNAME: &str = "workspace-cache";
pub const WORKSPACE_STATUS_FILENAME: &str = "status.json";
pub const CACHE_TTL: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceRootStatus {
pub path: PathBuf,
pub status: SourceRootIndexState,
#[serde(default, with = "serde_time::option")]
pub last_indexed_at: Option<SystemTime>,
pub symbol_count: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub classpath_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SourceRootIndexState {
Ok,
Missing,
Building,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
#[non_exhaustive]
pub enum WorkspaceWarning {
MacroExpansionInvalidRoot {
source_root: PathBuf,
detail: String,
},
ClasspathProbeFailed {
source_root: PathBuf,
detail: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkspaceIndexStatus {
pub source_root_statuses: Vec<SourceRootStatus>,
pub missing_count: u32,
pub building_count: u32,
pub ok_count: u32,
pub error_count: u32,
#[serde(with = "serde_time")]
pub generated_at: SystemTime,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<WorkspaceWarning>,
}
impl WorkspaceIndexStatus {
#[must_use]
pub fn from_source_root_statuses(mut entries: Vec<SourceRootStatus>) -> Self {
entries.sort_by(|a, b| a.path.cmp(&b.path));
let mut missing_count: u32 = 0;
let mut building_count: u32 = 0;
let mut ok_count: u32 = 0;
let mut error_count: u32 = 0;
for entry in &entries {
match entry.status {
SourceRootIndexState::Missing => missing_count = missing_count.saturating_add(1),
SourceRootIndexState::Building => building_count = building_count.saturating_add(1),
SourceRootIndexState::Ok => ok_count = ok_count.saturating_add(1),
SourceRootIndexState::Error => error_count = error_count.saturating_add(1),
}
}
Self {
source_root_statuses: entries,
missing_count,
building_count,
ok_count,
error_count,
generated_at: SystemTime::now(),
warnings: Vec::new(),
}
}
#[must_use]
pub fn total(&self) -> u32 {
u32::try_from(self.source_root_statuses.len()).unwrap_or(u32::MAX)
}
pub fn push_warning(&mut self, warning: WorkspaceWarning) -> &mut Self {
self.warnings.push(warning);
self
}
#[must_use]
pub fn has_warnings(&self) -> bool {
!self.warnings.is_empty()
}
}
#[must_use]
pub fn cache_path(workspace_dir: &Path) -> PathBuf {
workspace_dir
.join(".sqry")
.join(WORKSPACE_CACHE_DIRNAME)
.join(WORKSPACE_STATUS_FILENAME)
}
pub fn read_cache(workspace_dir: &Path) -> Result<Option<WorkspaceIndexStatus>, WorkspaceError> {
let path = cache_path(workspace_dir);
let metadata = match fs::metadata(&path) {
Ok(m) => m,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(WorkspaceError::io(&path, err)),
};
let Ok(modified) = read_modified(&metadata) else {
return Ok(None);
};
let age = SystemTime::now()
.duration_since(modified)
.unwrap_or(Duration::ZERO);
if age > CACHE_TTL {
return Ok(None);
}
let bytes = fs::read(&path).map_err(|err| WorkspaceError::io(&path, err))?;
let status: WorkspaceIndexStatus =
serde_json::from_slice(&bytes).map_err(WorkspaceError::Serialization)?;
Ok(Some(status))
}
pub fn write_cache(
workspace_dir: &Path,
status: &WorkspaceIndexStatus,
) -> Result<(), WorkspaceError> {
let path = cache_path(workspace_dir);
let dir = path
.parent()
.expect("cache_path always returns a path with a parent");
fs::create_dir_all(dir).map_err(|err| WorkspaceError::io(dir, err))?;
let bytes = serde_json::to_vec_pretty(status).map_err(WorkspaceError::Serialization)?;
let tmp_path = temp_sibling_path(&path);
{
let mut file =
fs::File::create(&tmp_path).map_err(|err| WorkspaceError::io(&tmp_path, err))?;
file.write_all(&bytes)
.map_err(|err| WorkspaceError::io(&tmp_path, err))?;
file.sync_all()
.map_err(|err| WorkspaceError::io(&tmp_path, err))?;
}
if let Err(err) = fs::rename(&tmp_path, &path) {
let _ = fs::remove_file(&tmp_path);
return Err(WorkspaceError::io(&path, err));
}
Ok(())
}
#[cfg(not(test))]
fn read_modified(metadata: &fs::Metadata) -> std::io::Result<SystemTime> {
metadata.modified()
}
#[cfg(test)]
fn read_modified(metadata: &fs::Metadata) -> std::io::Result<SystemTime> {
if test_hooks::FORCE_MTIME_UNREADABLE.load(std::sync::atomic::Ordering::SeqCst) {
return Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"test hook: simulated unreadable mtime",
));
}
metadata.modified()
}
#[cfg(test)]
pub(crate) mod test_hooks {
use std::sync::atomic::AtomicBool;
pub(crate) static FORCE_MTIME_UNREADABLE: AtomicBool = AtomicBool::new(false);
}
fn temp_sibling_path(path: &Path) -> PathBuf {
let pid = std::process::id();
let nanos = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let mut name = path
.file_name()
.map(std::ffi::OsStr::to_os_string)
.unwrap_or_default();
name.push(format!(".tmp.{pid}.{nanos}"));
let mut tmp = path.to_path_buf();
tmp.set_file_name(name);
tmp
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use tempfile::tempdir;
fn sample_status() -> WorkspaceIndexStatus {
WorkspaceIndexStatus::from_source_root_statuses(vec![
SourceRootStatus {
path: PathBuf::from("/ws/a"),
status: SourceRootIndexState::Ok,
last_indexed_at: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)),
symbol_count: Some(42),
classpath_dir: None,
},
SourceRootStatus {
path: PathBuf::from("/ws/b"),
status: SourceRootIndexState::Missing,
last_indexed_at: None,
symbol_count: None,
classpath_dir: None,
},
])
}
#[test]
#[serial(workspace_cache_read)]
fn aggregate_cache_writes_and_reads_under_ttl() {
let temp = tempdir().unwrap();
let status = sample_status();
write_cache(temp.path(), &status).unwrap();
let read = read_cache(temp.path())
.unwrap()
.expect("cache hit expected");
assert_eq!(read.source_root_statuses, status.source_root_statuses);
assert_eq!(read.ok_count, 1);
assert_eq!(read.missing_count, 1);
}
#[test]
#[serial(workspace_cache_read)]
fn aggregate_cache_returns_none_when_absent() {
let temp = tempdir().unwrap();
assert!(read_cache(temp.path()).unwrap().is_none());
}
#[test]
#[serial(workspace_cache_read)]
fn aggregate_cache_returns_none_after_ttl() {
let temp = tempdir().unwrap();
let status = sample_status();
write_cache(temp.path(), &status).unwrap();
let path = cache_path(temp.path());
let stale = SystemTime::now() - (CACHE_TTL + Duration::from_secs(5));
let f = fs::OpenOptions::new().write(true).open(&path).unwrap();
f.set_modified(stale).unwrap();
drop(f);
assert!(read_cache(temp.path()).unwrap().is_none());
}
#[test]
#[serial(workspace_cache_read)]
fn aggregate_cache_atomic_write_no_partial_files() {
let temp = tempdir().unwrap();
let status = sample_status();
write_cache(temp.path(), &status).unwrap();
let cache_dir = temp.path().join(".sqry").join(WORKSPACE_CACHE_DIRNAME);
let leftovers: Vec<_> = fs::read_dir(&cache_dir)
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
.collect();
assert!(
leftovers.is_empty(),
"expected no tempfile leftovers, got {leftovers:?}"
);
}
#[test]
#[serial(workspace_cache_read)]
fn aggregate_cache_atomic_write_visible_only_complete() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
let temp = tempdir().unwrap();
let dir = temp.path().to_path_buf();
let stop = Arc::new(AtomicBool::new(false));
write_cache(&dir, &sample_status()).unwrap();
let writer_stop = Arc::clone(&stop);
let writer_dir = dir.clone();
let writer = thread::spawn(move || {
while !writer_stop.load(Ordering::Relaxed) {
write_cache(&writer_dir, &sample_status()).unwrap();
}
});
let reads: usize = 5_000;
let mut hits: usize = 0;
let mut misses: usize = 0;
for _ in 0..reads {
match read_cache(&dir) {
Ok(Some(s)) => {
assert_eq!(
s.source_root_statuses.len(),
2,
"concurrent read returned an incomplete status payload",
);
hits += 1;
}
Ok(None) => misses += 1,
Err(err) => panic!(
"read_cache observed a torn / partial status.json during \
concurrent writes: {err:?}"
),
}
}
stop.store(true, Ordering::Relaxed);
writer.join().unwrap();
assert!(
hits > 0,
"expected at least some cache hits, got {hits} hits / {misses} misses"
);
}
#[test]
#[serial(workspace_cache_read)]
fn aggregate_cache_returns_none_when_mtime_unreadable() {
use std::sync::atomic::Ordering;
let temp = tempdir().unwrap();
let status = sample_status();
write_cache(temp.path(), &status).unwrap();
assert!(
read_cache(temp.path()).unwrap().is_some(),
"baseline read should hit"
);
test_hooks::FORCE_MTIME_UNREADABLE.store(true, Ordering::SeqCst);
let result = read_cache(temp.path());
test_hooks::FORCE_MTIME_UNREADABLE.store(false, Ordering::SeqCst);
assert!(
matches!(result, Ok(None)),
"unreadable mtime must yield Ok(None), got {result:?}"
);
}
#[test]
fn aggregate_status_summary_counts_match_entries() {
let status = sample_status();
assert_eq!(status.total(), 2);
assert_eq!(status.ok_count, 1);
assert_eq!(status.missing_count, 1);
assert_eq!(status.building_count, 0);
assert_eq!(status.error_count, 0);
}
}