use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use astrid_core::dirs::AstridHome;
use astrid_core::principal::PrincipalId;
use astrid_core::profile::{PrincipalProfile, ProfileError, ProfileResult};
#[derive(Debug)]
pub struct PrincipalProfileCache {
astrid_home: AstridHome,
cache: RwLock<HashMap<PrincipalId, Arc<PrincipalProfile>>>,
}
impl PrincipalProfileCache {
pub fn new() -> ProfileResult<Self> {
let astrid_home = AstridHome::resolve().map_err(|e| {
ProfileError::Io(std::io::Error::other(format!(
"failed to resolve AstridHome: {e}"
)))
})?;
Ok(Self::with_home(astrid_home))
}
#[must_use]
pub fn with_home(astrid_home: AstridHome) -> Self {
Self {
astrid_home,
cache: RwLock::new(HashMap::new()),
}
}
pub fn resolve(&self, principal: &PrincipalId) -> ProfileResult<Arc<PrincipalProfile>> {
if let Some(profile) = self
.cache
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(principal)
{
return Ok(Arc::clone(profile));
}
let profile = Arc::new(PrincipalProfile::load(&self.astrid_home, principal)?);
let mut w = self
.cache
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let entry = w.entry(principal.clone()).or_insert(profile);
Ok(Arc::clone(entry))
}
pub fn invalidate(&self, principal: &PrincipalId) {
self.cache
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(principal);
}
#[cfg(test)]
#[must_use]
pub(crate) fn len(&self) -> usize {
self.cache
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::Arc;
use astrid_core::principal::PrincipalId;
use astrid_core::profile::{
CURRENT_PROFILE_VERSION, DEFAULT_MAX_BACKGROUND_PROCESSES,
DEFAULT_MAX_IPC_THROUGHPUT_BYTES, DEFAULT_MAX_MEMORY_BYTES, DEFAULT_MAX_TIMEOUT_SECS,
PrincipalProfile,
};
fn fixture() -> (tempfile::TempDir, PrincipalProfileCache) {
let dir = tempfile::tempdir().expect("tempdir");
let home = AstridHome::from_path(dir.path());
let cache = PrincipalProfileCache::with_home(home);
(dir, cache)
}
fn principal(name: &str) -> PrincipalId {
PrincipalId::new(name).expect("valid principal")
}
fn write_profile(dir: &tempfile::TempDir, p: &PrincipalId, contents: &str) {
let home = AstridHome::from_path(dir.path());
let profiles_dir = home.profiles_dir();
fs::create_dir_all(&profiles_dir).expect("mkdir etc/profiles");
fs::write(home.profile_path(p), contents).expect("write profile");
}
#[test]
fn missing_file_returns_default_and_caches_it() {
let (_dir, cache) = fixture();
let p = principal("alice");
let profile = cache.resolve(&p).expect("resolve missing");
assert_eq!(*profile, PrincipalProfile::default());
assert_eq!(cache.len(), 1, "missing-file path must still cache");
let profile2 = cache.resolve(&p).expect("resolve cached");
assert!(Arc::ptr_eq(&profile, &profile2));
}
#[test]
fn populated_profile_loaded_once() {
let (dir, cache) = fixture();
let p = principal("bob");
write_profile(
&dir,
&p,
&format!(
"profile_version = {CURRENT_PROFILE_VERSION}\n\
[quotas]\n\
max_memory_bytes = 16777216\n\
max_timeout_secs = 42\n\
max_ipc_throughput_bytes = 524288\n\
max_background_processes = 2\n\
max_storage_bytes = 1048576\n"
),
);
let profile = cache.resolve(&p).expect("resolve populated");
assert_eq!(profile.quotas.max_memory_bytes, 16_777_216);
assert_eq!(profile.quotas.max_timeout_secs, 42);
assert_eq!(profile.quotas.max_ipc_throughput_bytes, 524_288);
assert_eq!(profile.quotas.max_background_processes, 2);
assert_eq!(profile.quotas.max_storage_bytes, 1_048_576);
}
#[test]
fn malformed_profile_is_hard_error_no_fallback() {
let (dir, cache) = fixture();
let p = principal("mallory");
write_profile(&dir, &p, "this is = = not [ valid toml");
let err = cache
.resolve(&p)
.expect_err("malformed TOML must not silently fall back");
assert!(matches!(err, ProfileError::Parse(_)), "got: {err:?}");
assert_eq!(cache.len(), 0);
let err2 = cache.resolve(&p).expect_err("still fails on retry");
assert!(matches!(err2, ProfileError::Parse(_)));
}
#[test]
fn invalid_profile_version_is_hard_error() {
let (dir, cache) = fixture();
let p = principal("future");
write_profile(
&dir,
&p,
&format!("profile_version = {}\n", CURRENT_PROFILE_VERSION + 1),
);
let err = cache.resolve(&p).expect_err("future version rejected");
assert!(matches!(err, ProfileError::Invalid(_)), "got: {err:?}");
}
#[test]
fn two_principals_have_independent_entries() {
let (dir, cache) = fixture();
let a = principal("alice2");
let b = principal("bob2");
write_profile(
&dir,
&a,
&format!(
"profile_version = {CURRENT_PROFILE_VERSION}\n\
[quotas]\n\
max_memory_bytes = 16777216\n"
),
);
let pa = cache.resolve(&a).expect("alice");
let pb = cache.resolve(&b).expect("bob");
assert_eq!(pa.quotas.max_memory_bytes, 16_777_216);
assert_eq!(pb.quotas.max_memory_bytes, DEFAULT_MAX_MEMORY_BYTES);
assert_eq!(pb.quotas.max_timeout_secs, DEFAULT_MAX_TIMEOUT_SECS);
assert_eq!(
pb.quotas.max_ipc_throughput_bytes,
DEFAULT_MAX_IPC_THROUGHPUT_BYTES
);
assert_eq!(
pb.quotas.max_background_processes,
DEFAULT_MAX_BACKGROUND_PROCESSES
);
}
#[test]
fn invalidate_forces_reload() {
let (dir, cache) = fixture();
let p = principal("reloader");
let first = cache.resolve(&p).expect("first resolve");
assert_eq!(first.quotas.max_memory_bytes, DEFAULT_MAX_MEMORY_BYTES);
write_profile(
&dir,
&p,
&format!(
"profile_version = {CURRENT_PROFILE_VERSION}\n\
[quotas]\n\
max_memory_bytes = 8388608\n"
),
);
cache.invalidate(&p);
let second = cache.resolve(&p).expect("second resolve");
assert_eq!(second.quotas.max_memory_bytes, 8_388_608);
}
#[test]
fn concurrent_readers_do_not_race() {
let (_dir, cache) = fixture();
let cache = Arc::new(cache);
let p = principal("racer");
let mut handles = Vec::new();
for _ in 0..8 {
let c = Arc::clone(&cache);
let pid = p.clone();
handles.push(std::thread::spawn(move || {
let _ = c.resolve(&pid).expect("resolve");
}));
}
for h in handles {
h.join().expect("join");
}
assert_eq!(cache.len(), 1, "only one entry expected");
}
}