prvng_platform/crates/ncl-sync/src/manifest.rs

179 lines
5.8 KiB
Rust
Raw Normal View History

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")
}