178 lines
5.8 KiB
Rust
178 lines
5.8 KiB
Rust
use anyhow::{Context, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ManifestEntry {
|
|
/// SHA256(file_content + sorted_import_paths + format) — identical to plugin key.
|
|
pub cache_key: String,
|
|
pub source_mtime: u64,
|
|
pub import_paths: Vec<String>,
|
|
pub cached_at: u64,
|
|
}
|
|
|
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
|
pub struct Manifest {
|
|
entries: HashMap<String, ManifestEntry>,
|
|
}
|
|
|
|
impl Manifest {
|
|
pub fn load(cache_dir: &Path) -> Result<Self> {
|
|
let path = manifest_path(cache_dir);
|
|
if !path.exists() {
|
|
return Ok(Self::default());
|
|
}
|
|
let content = fs::read_to_string(&path)
|
|
.with_context(|| format!("reading manifest {}", path.display()))?;
|
|
serde_json::from_str(&content).with_context(|| "parsing manifest JSON")
|
|
}
|
|
|
|
pub fn save(&self, cache_dir: &Path) -> Result<()> {
|
|
let path = manifest_path(cache_dir);
|
|
let tmp = path.with_extension("tmp");
|
|
fs::write(&tmp, serde_json::to_string_pretty(self)?)
|
|
.with_context(|| format!("writing manifest tmp {}", tmp.display()))?;
|
|
fs::rename(&tmp, &path).with_context(|| "renaming manifest")
|
|
}
|
|
|
|
pub fn is_stale(&self, canonical: &Path) -> bool {
|
|
let key = canonical.to_string_lossy().into_owned();
|
|
let Some(entry) = self.entries.get(&key) else {
|
|
return true;
|
|
};
|
|
file_mtime(canonical).unwrap_or(0) != entry.source_mtime
|
|
}
|
|
|
|
pub fn update(&mut self, canonical: &Path, import_paths: &[String], cache_key: &str) {
|
|
let key = canonical.to_string_lossy().into_owned();
|
|
self.entries.insert(
|
|
key,
|
|
ManifestEntry {
|
|
cache_key: cache_key.to_owned(),
|
|
source_mtime: file_mtime(canonical).unwrap_or(0),
|
|
import_paths: import_paths.to_vec(),
|
|
cached_at: now_secs(),
|
|
},
|
|
);
|
|
}
|
|
|
|
pub fn evict(&mut self, canonical: &Path) {
|
|
self.entries.remove(&canonical.to_string_lossy().into_owned());
|
|
}
|
|
|
|
pub fn entry(&self, canonical: &Path) -> Option<&ManifestEntry> {
|
|
self.entries.get(&canonical.to_string_lossy().into_owned())
|
|
}
|
|
|
|
pub fn all_entries(&self) -> impl Iterator<Item = (&str, &ManifestEntry)> {
|
|
self.entries.iter().map(|(k, v)| (k.as_str(), v))
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn stale_paths(&self) -> Vec<PathBuf> {
|
|
self.entries
|
|
.iter()
|
|
.filter_map(|(key, entry)| {
|
|
let path = PathBuf::from(key);
|
|
if file_mtime(&path).unwrap_or(0) != entry.source_mtime {
|
|
Some(path)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
/// Derive the cache key — must match the plugin's `compute_cache_key` exactly.
|
|
///
|
|
/// Key = SHA256(file_content + format).
|
|
/// Import paths are NOT part of the key — see plugin's helpers.rs for rationale.
|
|
pub fn derive_cache_key(canonical: &Path, _import_paths: &[String], format: &str) -> Result<String> {
|
|
let content = fs::read_to_string(canonical)
|
|
.with_context(|| format!("reading {} for cache key", canonical.display()))?;
|
|
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(content.as_bytes());
|
|
hasher.update(format.as_bytes());
|
|
|
|
Ok(format!("{:x}", hasher.finalize()))
|
|
}
|
|
|
|
/// Resolve the cache directory FOR A FILE.
|
|
///
|
|
/// Two caches coexist:
|
|
/// - **Global** (`~/.cache/provisioning/config-cache/`): files under `$PROVISIONING`
|
|
/// (extensions, schemas) — shared across workspaces.
|
|
/// - **Workspace** (`<workspace>/.ncl-cache/`): files under the workspace
|
|
/// (state, components, settings) — scoped to the workspace.
|
|
///
|
|
/// Priority:
|
|
/// 1. `$NCL_CACHE_DIR` — explicit override (e.g. CI)
|
|
/// 2. If file is under `$PROVISIONING` → global cache
|
|
/// 3. If `workspace` is given and file is under it → workspace cache
|
|
/// 4. Fallback: global cache
|
|
///
|
|
/// Must match `get_cache_dir_for_file()` in the plugin's helpers.rs.
|
|
pub fn resolve_cache_dir_for_file(file_path: &Path, workspace: Option<&Path>) -> PathBuf {
|
|
if let Ok(dir) = std::env::var("NCL_CACHE_DIR") {
|
|
return PathBuf::from(dir);
|
|
}
|
|
if let Ok(prov) = std::env::var("PROVISIONING") {
|
|
let prov = PathBuf::from(prov);
|
|
if file_path.starts_with(&prov) {
|
|
return global_cache_dir();
|
|
}
|
|
}
|
|
if let Some(ws) = workspace {
|
|
if file_path.starts_with(ws) {
|
|
return ws.join(".ncl-cache");
|
|
}
|
|
}
|
|
global_cache_dir()
|
|
}
|
|
|
|
/// Cache dir for "generic" operations (stats, invalidate by path) without a file context.
|
|
/// Prefers workspace-local if a workspace is given; falls back to global.
|
|
pub fn resolve_cache_dir(workspace: Option<&Path>) -> PathBuf {
|
|
if let Ok(dir) = std::env::var("NCL_CACHE_DIR") {
|
|
return PathBuf::from(dir);
|
|
}
|
|
if let Some(ws) = workspace {
|
|
return ws.join(".ncl-cache");
|
|
}
|
|
global_cache_dir()
|
|
}
|
|
|
|
/// Global cache — shared across workspaces for files under `$PROVISIONING`.
|
|
pub fn global_cache_dir() -> PathBuf {
|
|
dirs::cache_dir()
|
|
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
|
.join("provisioning")
|
|
.join("config-cache")
|
|
}
|
|
|
|
pub fn file_mtime(path: &Path) -> Option<u64> {
|
|
fs::metadata(path)
|
|
.ok()?
|
|
.modified()
|
|
.ok()?
|
|
.duration_since(UNIX_EPOCH)
|
|
.ok()
|
|
.map(|d| d.as_secs())
|
|
}
|
|
|
|
fn now_secs() -> u64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs()
|
|
}
|
|
|
|
fn manifest_path(cache_dir: &Path) -> PathBuf {
|
|
cache_dir.join("manifest.json")
|
|
}
|