use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::error::{GroxError, Result};
use crate::resolve::CrateSource;
use crate::types::DocIndex;
const FORMAT_VERSION: u32 = 4;
#[derive(Serialize, Deserialize)]
struct CachedData {
header: CacheHeader,
index: DocIndex,
}
#[derive(Serialize, Deserialize)]
struct CacheHeader {
grox_version: String,
format_version: u32,
created_at: u64,
metadata: CacheMetadata,
}
#[derive(Serialize, Deserialize)]
enum CacheMetadata {
CurrentCrate {
source_mtime: u64,
},
Dependency {
version: String,
},
StdLib {
toolchain_version: String,
},
External {
version: String,
},
}
pub(crate) fn cache_suffix(features: &crate::cli::FeatureFlags, private: bool) -> String {
let mut suffix = features.cache_suffix();
if private {
suffix.push_str("-priv");
}
suffix
}
pub(crate) fn cache_path(
source: &CrateSource,
feature_suffix: &str,
ctx: Option<&crate::resolve::ProjectContext>,
) -> Option<PathBuf> {
match source {
CrateSource::CurrentCrate { name, .. } => {
let target_dir = ctx?.workspace_target_directory();
let filename = format!("{name}{feature_suffix}.groxide");
Some(target_dir.join("groxide").join(filename))
}
CrateSource::Dependency { name, version, .. } => {
let cache_dir = dirs::cache_dir()?;
let filename = format!("{name}-{version}{feature_suffix}.groxide");
Some(cache_dir.join("groxide").join("deps").join(filename))
}
CrateSource::Stdlib { name } => {
let cache_dir = dirs::cache_dir()?;
let toolchain = crate::stdlib::get_toolchain_hash().ok()?;
let filename = format!("{name}-{toolchain}{feature_suffix}.groxide");
Some(cache_dir.join("groxide").join("stdlib").join(filename))
}
CrateSource::External { name, version } => {
let cache_dir = dirs::cache_dir()?;
let ver = version.as_deref().unwrap_or("latest");
let filename = format!("{name}-{ver}{feature_suffix}.groxide");
Some(cache_dir.join("groxide").join("external").join(filename))
}
}
}
pub(crate) fn current_crate_source_mtime(
package_dir: &Path,
workspace_root: Option<&Path>,
) -> Option<u64> {
fn mtime_secs(path: &Path) -> Option<u64> {
fs::metadata(path)
.ok()?
.modified()
.ok()?
.duration_since(UNIX_EPOCH)
.ok()
.map(|d| d.as_secs())
}
let mut max = mtime_secs(&package_dir.join("Cargo.toml"))?;
if let Some(t) = mtime_secs(&package_dir.join("build.rs")) {
max = max.max(t);
}
let src_dir = package_dir.join("src");
if src_dir.is_dir() {
let mut stack = vec![src_dir];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
stack.push(path);
} else if file_type.is_file() && path.extension().is_some_and(|e| e == "rs") {
if let Some(t) = mtime_secs(&path) {
max = max.max(t);
}
}
}
}
}
if let Some(ws_root) = workspace_root {
if ws_root != package_dir {
if let Some(t) = mtime_secs(&ws_root.join("Cargo.toml")) {
max = max.max(t);
}
}
if let Some(t) = mtime_secs(&ws_root.join("Cargo.lock")) {
max = max.max(t);
}
}
Some(max)
}
pub(crate) fn load_cached(
path: &Path,
source: &CrateSource,
ctx: Option<&crate::resolve::ProjectContext>,
) -> Option<DocIndex> {
if !path.exists() {
return None;
}
#[cfg(debug_assertions)]
if is_binary_newer_than_cache(path) {
return None;
}
let bytes = fs::read(path).ok()?;
let data: CachedData = rmp_serde::from_slice(&bytes).ok()?;
let current_mtime = compute_source_mtime(source, ctx);
if !is_cache_valid(&data.header, source, current_mtime) {
return None;
}
Some(data.index)
}
pub(crate) fn save_to_cache(
path: &Path,
index: &DocIndex,
source: &CrateSource,
ctx: Option<&crate::resolve::ProjectContext>,
) {
if let Err(e) = save_to_cache_inner(path, index, source, ctx) {
eprintln!("[grox] warning: failed to save cache: {e}");
}
}
fn compute_source_mtime(
source: &CrateSource,
ctx: Option<&crate::resolve::ProjectContext>,
) -> Option<u64> {
match source {
CrateSource::CurrentCrate { manifest_path, .. } => {
let package_dir = manifest_path
.parent()
.expect("invariant: manifest_path has a parent");
let workspace_root = ctx.map(crate::resolve::ProjectContext::workspace_root);
current_crate_source_mtime(package_dir, workspace_root.as_deref())
}
_ => None,
}
}
fn save_to_cache_inner(
path: &Path,
index: &DocIndex,
source: &CrateSource,
ctx: Option<&crate::resolve::ProjectContext>,
) -> Result<()> {
let mtime = compute_source_mtime(source, ctx);
let header = create_header(source, mtime);
let data = CachedData {
header,
index: index.clone(),
};
let bytes = rmp_serde::to_vec(&data).map_err(|e| GroxError::CacheSerializationFailed {
message: format!("serialize: {e}"),
})?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let pid = std::process::id();
let temp_path = path.with_extension(format!("tmp.{pid}"));
if let Err(e) = fs::write(&temp_path, &bytes) {
return Err(GroxError::CacheSerializationFailed {
message: format!("write temp file: {e}"),
});
}
if let Err(e) = fs::rename(&temp_path, path) {
let _ = fs::remove_file(&temp_path);
return Err(GroxError::CacheSerializationFailed {
message: format!("rename: {e}"),
});
}
Ok(())
}
fn create_header(source: &CrateSource, current_mtime: Option<u64>) -> CacheHeader {
let created_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let metadata = match source {
CrateSource::CurrentCrate { .. } => CacheMetadata::CurrentCrate {
source_mtime: current_mtime.unwrap_or(0),
},
CrateSource::Dependency { version: ver, .. } => CacheMetadata::Dependency {
version: ver.clone(),
},
CrateSource::Stdlib { .. } => {
let toolchain =
crate::stdlib::get_toolchain_hash().unwrap_or_else(|_| "unknown".to_string());
CacheMetadata::StdLib {
toolchain_version: toolchain,
}
}
CrateSource::External { version, .. } => CacheMetadata::External {
version: version.as_deref().unwrap_or("unknown").to_string(),
},
};
CacheHeader {
grox_version: env!("CARGO_PKG_VERSION").to_string(),
format_version: FORMAT_VERSION,
created_at,
metadata,
}
}
fn is_cache_valid(header: &CacheHeader, source: &CrateSource, current_mtime: Option<u64>) -> bool {
if header.grox_version != env!("CARGO_PKG_VERSION") {
return false;
}
if header.format_version != FORMAT_VERSION {
return false;
}
match (&header.metadata, source) {
(
CacheMetadata::CurrentCrate {
source_mtime: cached_mtime,
},
CrateSource::CurrentCrate { .. },
) => current_mtime.is_some_and(|now| now == *cached_mtime),
(
CacheMetadata::Dependency {
version: cached_version,
},
CrateSource::Dependency { version, .. },
) => cached_version == version,
(
CacheMetadata::StdLib {
toolchain_version: cached_toolchain,
},
CrateSource::Stdlib { .. },
) => {
let current =
crate::stdlib::get_toolchain_hash().unwrap_or_else(|_| "unknown".to_string());
cached_toolchain == ¤t
}
(
CacheMetadata::External {
version: cached_version,
},
CrateSource::External { version, .. },
) => {
let current = version.as_deref().unwrap_or("unknown");
cached_version == current
}
_ => false,
}
}
#[cfg(debug_assertions)]
fn is_binary_newer_than_cache(cache_path: &Path) -> bool {
let Ok(binary_path) = std::env::current_exe() else {
return false;
};
let Ok(binary_mtime) = fs::metadata(&binary_path).and_then(|m| m.modified()) else {
return false;
};
let Ok(cache_mtime) = fs::metadata(cache_path).and_then(|m| m.modified()) else {
return true; };
binary_mtime > cache_mtime
}
pub(crate) fn clear_global_cache() -> Option<PathBuf> {
let cache_dir = dirs::cache_dir()?.join("groxide");
if cache_dir.exists() {
let _ = fs::remove_dir_all(&cache_dir);
}
Some(cache_dir)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::CrateSpec;
use crate::types::{DocIndex, IndexItem, ItemKind, SourceSpan};
use tempfile::TempDir;
fn make_test_index() -> DocIndex {
let mut index = DocIndex::new("testcrate".to_string(), "1.0.0".to_string());
index.add_item(IndexItem {
path: "testcrate::Foo".to_string(),
name: "Foo".to_string(),
kind: ItemKind::Struct,
signature: "pub struct Foo".to_string(),
docs: "A test struct.".to_string(),
summary: "A test struct.".to_string(),
span: SourceSpan {
file: "src/lib.rs".to_string(),
line_start: 1,
line_end: 5,
},
children: Vec::new(),
is_public: true,
has_body: false,
feature_gate: None,
reexport_source: None,
});
index
}
fn make_dep_source(tmp: &Path) -> CrateSource {
CrateSource::Dependency {
manifest_path: tmp.join("Cargo.toml"),
name: "testcrate".to_string(),
version: "1.0.0".to_string(),
}
}
#[test]
fn round_trip_dependency_save_load() {
let tmp = TempDir::new().unwrap();
let tmp_path = tmp.path();
let source = make_dep_source(tmp_path);
let index = make_test_index();
let cache_file = tmp_path.join("deps/testcrate-1.0.0.groxide");
save_to_cache(&cache_file, &index, &source, None);
let loaded = load_cached(&cache_file, &source, None);
assert!(loaded.is_some());
assert_eq!(loaded.unwrap().crate_name, "testcrate");
}
#[test]
fn cache_path_for_current_crate_requires_project_context() {
let tmp = TempDir::new().unwrap();
let source = CrateSource::CurrentCrate {
manifest_path: tmp.path().join("Cargo.toml"),
name: "mycrate".to_string(),
version: "2.3.4".to_string(),
};
assert!(cache_path(&source, "", None).is_none());
}
#[test]
fn cache_path_for_current_crate_with_ctx_lives_in_workspace_target() {
let ctx = crate::resolve::ProjectContext::discover(None).expect("groxide project context");
let source = ctx.resolve_crate(&CrateSpec::CurrentCrate);
let path = cache_path(&source, "", Some(&ctx)).expect("Some(path) when ctx is provided");
assert!(
path.parent().is_some_and(|p| p.ends_with("groxide")),
"path should be in <target>/groxide/, got {path:?}"
);
assert_eq!(
path.file_name().and_then(|n| n.to_str()),
Some("groxide.groxide"),
);
}
#[test]
fn cache_path_includes_version_for_dependency() {
let tmp = TempDir::new().unwrap();
let source = CrateSource::Dependency {
manifest_path: tmp.path().join("Cargo.toml"),
name: "serde".to_string(),
version: "1.0.210".to_string(),
};
let path = cache_path(&source, "", None).unwrap();
assert!(
path.to_str().unwrap().contains("serde-1.0.210.groxide"),
"path should include version: {path:?}"
);
}
#[test]
fn cache_path_includes_version_for_external() {
let source = CrateSource::External {
name: "tokio".to_string(),
version: Some("1.40.0".to_string()),
};
let path = cache_path(&source, "", None).unwrap();
assert!(
path.to_str().unwrap().contains("tokio-1.40.0.groxide"),
"path should include version: {path:?}"
);
}
#[test]
fn cache_path_changes_with_feature_suffix() {
let tmp = TempDir::new().unwrap();
let source = CrateSource::Dependency {
manifest_path: tmp.path().join("Cargo.toml"),
name: "mycrate".to_string(),
version: "1.0.0".to_string(),
};
let path_default = cache_path(&source, "", None).unwrap();
let path_feat = cache_path(&source, "-feat_0a1b2c3d4e5f6a7b", None).unwrap();
assert_ne!(path_default, path_feat);
assert!(
path_feat
.to_str()
.unwrap()
.contains("-feat_0a1b2c3d4e5f6a7b"),
"path should include feature suffix: {path_feat:?}"
);
}
#[test]
fn cache_path_no_suffix_for_default_features() {
let tmp = TempDir::new().unwrap();
let source = CrateSource::Dependency {
manifest_path: tmp.path().join("Cargo.toml"),
name: "mycrate".to_string(),
version: "1.0.0".to_string(),
};
let path = cache_path(&source, "", None).unwrap();
let filename = path.file_name().unwrap().to_str().unwrap();
assert_eq!(filename, "mycrate-1.0.0.groxide");
}
#[test]
fn atomic_write_no_temp_file_left_behind() {
let tmp = TempDir::new().unwrap();
let tmp_path = tmp.path();
let source = make_dep_source(tmp_path);
let index = make_test_index();
let cache_dir = tmp_path.join("deps");
let cache_file = cache_dir.join("testcrate-1.0.0.groxide");
save_to_cache(&cache_file, &index, &source, None);
let entries: Vec<_> = fs::read_dir(&cache_dir)
.unwrap()
.flatten()
.filter(|e| e.path().to_str().unwrap_or("").contains(".tmp."))
.collect();
assert!(
entries.is_empty(),
"no temp files should remain after successful save"
);
}
#[test]
fn stale_cache_detected_when_dep_version_changes() {
let tmp = TempDir::new().unwrap();
let tmp_path = tmp.path();
let source_v1 = CrateSource::Dependency {
manifest_path: tmp_path.join("Cargo.toml"),
name: "serde".to_string(),
version: "1.0.0".to_string(),
};
let source_v2 = CrateSource::Dependency {
manifest_path: tmp_path.join("Cargo.toml"),
name: "serde".to_string(),
version: "1.0.1".to_string(),
};
let index = make_test_index();
let cache_file = tmp_path.join("deps/serde-1.0.0.groxide");
save_to_cache(&cache_file, &index, &source_v1, None);
assert!(
load_cached(&cache_file, &source_v1, None).is_some(),
"cache should be valid for v1"
);
assert!(
load_cached(&cache_file, &source_v2, None).is_none(),
"cache should be invalid for v2"
);
}
#[test]
fn load_cached_returns_none_for_missing_file() {
let tmp = TempDir::new().unwrap();
let source = make_dep_source(tmp.path());
let result = load_cached(&tmp.path().join("nonexistent.groxide"), &source, None);
assert!(result.is_none());
}
#[test]
fn load_cached_returns_none_for_corrupted_file() {
let tmp = TempDir::new().unwrap();
let cache_file = tmp.path().join("corrupt.groxide");
fs::write(&cache_file, b"not valid msgpack data").unwrap();
let source = make_dep_source(tmp.path());
let result = load_cached(&cache_file, &source, None);
assert!(result.is_none());
}
#[test]
fn cache_path_stdlib_uses_global_cache() {
let source = CrateSource::Stdlib {
name: "std".to_string(),
};
let Some(path) = cache_path(&source, "", None) else {
eprintln!("SKIP: nightly not available for toolchain hash");
return;
};
let path_str = path.to_str().unwrap();
assert!(
path_str.contains("groxide/stdlib/"),
"stdlib should use global cache: {path_str}"
);
let filename = path.file_name().unwrap().to_str().unwrap();
assert!(
filename.starts_with("std-"),
"should start with crate name and dash: {filename}"
);
assert!(
filename.ends_with(".groxide"),
"should end with .groxide: {filename}"
);
}
#[test]
fn cache_path_external_uses_global_cache() {
let source = CrateSource::External {
name: "tokio".to_string(),
version: Some("1.40.0".to_string()),
};
let path = cache_path(&source, "", None).unwrap();
let path_str = path.to_str().unwrap();
assert!(
path_str.contains("groxide/external/"),
"external should use global cache: {path_str}"
);
}
#[test]
fn cache_path_dependency_uses_global_cache() {
let tmp = TempDir::new().unwrap();
let source = CrateSource::Dependency {
manifest_path: tmp.path().join("Cargo.toml"),
name: "serde".to_string(),
version: "1.0.0".to_string(),
};
let path = cache_path(&source, "", None).unwrap();
let path_str = path.to_str().unwrap();
assert!(
path_str.contains("groxide/deps/"),
"dependency should use global cache: {path_str}"
);
}
#[test]
fn clear_global_cache_returns_some_path() {
let result = clear_global_cache();
assert!(
result.is_some(),
"should return a path on systems with a cache dir"
);
let path = result.unwrap();
assert!(
path.ends_with("groxide"),
"path should end with 'groxide': {path:?}"
);
}
}