use std::path::PathBuf;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
pub const ACTIVE_PROJECT_SET_SCHEMA: u32 = 1;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ActiveProjectSet {
#[serde(default)]
pub schema: u32,
#[serde(default)]
pub generation: u64,
#[serde(default)]
pub published_at_unix: u64,
#[serde(default)]
pub projects: Vec<ActiveProject>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ActiveProject {
#[serde(default)]
pub root: PathBuf,
#[serde(default)]
pub palace_id: Option<String>,
#[serde(default)]
pub index_ids: Vec<String>,
#[serde(default)]
pub session_ids: Vec<String>,
#[serde(default)]
pub last_activity_unix: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct ResidencySnapshot {
stale_after: Duration,
set: Option<ActiveProjectSet>,
last_success: Option<Instant>,
}
impl ResidencySnapshot {
pub fn new(stale_after: Duration) -> Self {
Self {
stale_after,
set: None,
last_success: None,
}
}
pub fn observe(&mut self, set: ActiveProjectSet, now: Instant) {
self.set = Some(set);
self.last_success = Some(now);
}
pub fn on_pull_failure(&mut self, now: Instant) {
if !self.is_fresh(now) {
self.set = None;
}
}
pub fn is_fresh(&self, now: Instant) -> bool {
self.last_success
.is_some_and(|last| now.saturating_duration_since(last) < self.stale_after)
}
pub fn pinned(&self, now: Instant) -> Option<&ActiveProjectSet> {
if self.is_fresh(now) {
self.set.as_ref()
} else {
None
}
}
}
pub const DEFAULT_RESIDENCY_PULL_SECS: u64 = 30;
pub const DEFAULT_RESIDENCY_STALE_SECS: u64 = 600;
pub const DEFAULT_RESIDENCY_GRACE_SECS: u64 = 120;
pub const RESIDENCY_PULL_SECS_ENV: &str = "TRUSTY_RESIDENCY_PULL_SECS";
pub const RESIDENCY_STALE_SECS_ENV: &str = "TRUSTY_RESIDENCY_STALE_SECS";
pub const RESIDENCY_GRACE_SECS_ENV: &str = "TRUSTY_RESIDENCY_GRACE_SECS";
pub const RESIDENCY_ENABLED_ENV: &str = "TRUSTY_RESIDENCY";
pub fn residency_pull_secs(raw: Option<&str>) -> u64 {
parse_secs(raw, DEFAULT_RESIDENCY_PULL_SECS)
}
pub fn residency_stale_secs(raw: Option<&str>) -> u64 {
parse_secs(raw, DEFAULT_RESIDENCY_STALE_SECS)
}
pub fn residency_grace_secs(raw: Option<&str>) -> u64 {
parse_secs(raw, DEFAULT_RESIDENCY_GRACE_SECS)
}
pub fn residency_enabled(raw: Option<&str>) -> bool {
!matches!(raw.map(str::trim), Some(v) if v.eq_ignore_ascii_case("off"))
}
fn parse_secs(raw: Option<&str>, default: u64) -> u64 {
match raw.map(str::trim) {
Some(trimmed) if !trimmed.is_empty() => trimmed.parse::<u64>().unwrap_or(default),
_ => default,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_set(generation: u64) -> ActiveProjectSet {
ActiveProjectSet {
schema: ACTIVE_PROJECT_SET_SCHEMA,
generation,
published_at_unix: 1_700_000_000,
projects: vec![ActiveProject {
root: PathBuf::from("/repo"),
palace_id: Some("palace-1".to_string()),
..Default::default()
}],
}
}
#[test]
fn active_project_set_deserializes_from_an_empty_object() {
let set: ActiveProjectSet = serde_json::from_value(serde_json::json!({}))
.expect("every field is #[serde(default)]");
assert_eq!(set, ActiveProjectSet::default());
let project: ActiveProject = serde_json::from_value(serde_json::json!({}))
.expect("every field is #[serde(default)]");
assert_eq!(project, ActiveProject::default());
}
#[test]
fn active_project_set_deserializes_a_payload_with_an_unknown_field() {
let set: ActiveProjectSet = serde_json::from_value(serde_json::json!({
"schema": ACTIVE_PROJECT_SET_SCHEMA,
"generation": 3,
"published_at_unix": 1_700_000_000_u64,
"projects": [],
"future_field": "the producer is newer than this crate",
}))
.expect("an unknown field must not fail decode");
assert_eq!(set.generation, 3);
let project: ActiveProject = serde_json::from_value(serde_json::json!({
"root": "/repo",
"palace_id": "palace-1",
"index_ids": [],
"session_ids": [],
"last_activity_unix": null,
"future_field": 42,
}))
.expect("an unknown field must not fail decode");
assert_eq!(project.palace_id.as_deref(), Some("palace-1"));
}
#[test]
fn residency_snapshot_is_not_fresh_before_the_first_observe() {
let snapshot = ResidencySnapshot::new(Duration::from_secs(10));
let now = Instant::now();
assert!(!snapshot.is_fresh(now));
assert_eq!(snapshot.pinned(now), None);
}
#[test]
fn residency_snapshot_stays_pinned_across_a_pull_failure_while_fresh() {
let mut snapshot = ResidencySnapshot::new(Duration::from_secs(10));
let t0 = Instant::now();
let set = sample_set(1);
snapshot.observe(set.clone(), t0);
let t1 = t0 + Duration::from_secs(5);
snapshot.on_pull_failure(t1);
assert_eq!(snapshot.pinned(t1), Some(&set));
}
#[test]
fn residency_snapshot_clears_once_stale() {
let mut snapshot = ResidencySnapshot::new(Duration::from_secs(10));
let t0 = Instant::now();
snapshot.observe(sample_set(1), t0);
let t1 = t0 + Duration::from_secs(20);
snapshot.on_pull_failure(t1);
assert!(!snapshot.is_fresh(t1));
assert_eq!(snapshot.pinned(t1), None);
}
#[test]
fn residency_snapshot_observe_restores_after_staleness() {
let mut snapshot = ResidencySnapshot::new(Duration::from_secs(10));
let t0 = Instant::now();
snapshot.observe(sample_set(1), t0);
let t1 = t0 + Duration::from_secs(20);
snapshot.on_pull_failure(t1);
assert_eq!(snapshot.pinned(t1), None);
let t2 = t0 + Duration::from_secs(21);
let restored = sample_set(2);
snapshot.observe(restored.clone(), t2);
assert_eq!(snapshot.pinned(t2), Some(&restored));
}
#[test]
fn pinned_recomputes_staleness_live_with_no_intervening_call() {
let stale_after = Duration::from_secs(600);
let mut snapshot = ResidencySnapshot::new(stale_after);
let t0 = Instant::now();
let set = sample_set(1);
snapshot.observe(set.clone(), t0);
let still_within_window = t0 + stale_after - Duration::from_secs(1);
assert_eq!(snapshot.pinned(still_within_window), Some(&set));
let past_the_window = t0 + stale_after + Duration::from_secs(1);
assert_eq!(snapshot.pinned(past_the_window), None);
}
#[test]
fn residency_pull_secs_defaults_on_absence() {
assert_eq!(residency_pull_secs(None), DEFAULT_RESIDENCY_PULL_SECS);
}
#[test]
fn residency_pull_secs_defaults_on_garbage() {
assert_eq!(
residency_pull_secs(Some("not-a-number")),
DEFAULT_RESIDENCY_PULL_SECS
);
assert_eq!(residency_pull_secs(Some("")), DEFAULT_RESIDENCY_PULL_SECS);
assert_eq!(residency_pull_secs(Some(" ")), DEFAULT_RESIDENCY_PULL_SECS);
assert_eq!(residency_pull_secs(Some("-5")), DEFAULT_RESIDENCY_PULL_SECS);
}
#[test]
fn residency_pull_secs_reads_a_valid_value() {
assert_eq!(residency_pull_secs(Some("45")), 45);
assert_eq!(residency_pull_secs(Some(" 45 ")), 45);
}
#[test]
fn residency_stale_secs_defaults_on_absence() {
assert_eq!(residency_stale_secs(None), DEFAULT_RESIDENCY_STALE_SECS);
}
#[test]
fn residency_stale_secs_defaults_on_garbage() {
assert_eq!(
residency_stale_secs(Some("nope")),
DEFAULT_RESIDENCY_STALE_SECS
);
}
#[test]
fn residency_stale_secs_reads_a_valid_value() {
assert_eq!(residency_stale_secs(Some("900")), 900);
}
#[test]
fn residency_grace_secs_defaults_on_absence() {
assert_eq!(residency_grace_secs(None), DEFAULT_RESIDENCY_GRACE_SECS);
}
#[test]
fn residency_grace_secs_defaults_on_garbage() {
assert_eq!(
residency_grace_secs(Some("off")),
DEFAULT_RESIDENCY_GRACE_SECS
);
}
#[test]
fn residency_grace_secs_reads_a_valid_value() {
assert_eq!(residency_grace_secs(Some("60")), 60);
}
#[test]
fn residency_enabled_is_true_by_default() {
assert!(residency_enabled(None));
}
#[test]
fn residency_enabled_is_false_for_off() {
assert!(!residency_enabled(Some("off")));
}
#[test]
fn residency_enabled_ignores_case_and_whitespace() {
assert!(!residency_enabled(Some(" OFF ")));
assert!(!residency_enabled(Some("Off")));
}
#[test]
fn residency_enabled_stays_true_for_garbage() {
assert!(residency_enabled(Some("nah")));
assert!(residency_enabled(Some("")));
assert!(residency_enabled(Some("0")));
}
}