use anyhow::{Context, Result};
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
const MIN_DREAM_INTERVAL_HOURS: u64 = 24;
const MIN_SESSIONS_SINCE_DREAM: usize = 5;
const LAST_DREAM_FILE: &str = "last_dream_at";
const DREAM_LOCK_FILE: &str = "dream.lock";
static HELD_LOCKS: Mutex<Option<HashSet<PathBuf>>> = Mutex::new(None);
fn is_lock_held(lock_path: &Path) -> bool {
let mut guard = HELD_LOCKS.lock().unwrap_or_else(|e| e.into_inner());
let set = guard.get_or_insert_with(HashSet::new);
set.contains(lock_path)
}
fn mark_lock_held(lock_path: PathBuf) {
let mut guard = HELD_LOCKS.lock().unwrap_or_else(|e| e.into_inner());
let set = guard.get_or_insert_with(HashSet::new);
set.insert(lock_path);
}
fn mark_lock_released(lock_path: &Path) {
let mut guard = HELD_LOCKS.lock().unwrap_or_else(|e| e.into_inner());
if let Some(set) = guard.as_mut() {
set.remove(lock_path);
}
}
#[derive(Debug, Clone)]
pub struct AutoDreamState {
pub memory_root: PathBuf,
pub dream_interval_hours: u64,
pub min_sessions: usize,
}
impl AutoDreamState {
pub fn new(memory_root: PathBuf) -> Self {
Self {
memory_root,
dream_interval_hours: MIN_DREAM_INTERVAL_HOURS,
min_sessions: MIN_SESSIONS_SINCE_DREAM,
}
}
pub fn with_interval(mut self, hours: u64) -> Self {
self.dream_interval_hours = hours;
self
}
pub fn with_min_sessions(mut self, count: usize) -> Self {
self.min_sessions = count;
self
}
fn last_dream_path(&self) -> PathBuf {
self.memory_root.join(LAST_DREAM_FILE)
}
fn lock_path(&self) -> PathBuf {
self.memory_root.join(DREAM_LOCK_FILE)
}
pub fn read_last_dream_at(&self) -> u64 {
match fs::read_to_string(self.last_dream_path()) {
Ok(content) => content.trim().parse().unwrap_or(0),
Err(_) => 0,
}
}
fn write_last_dream_at(&self) -> Result<()> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if let Some(parent) = self.last_dream_path().parent() {
if !parent.exists() {
fs::create_dir_all(parent).with_context(|| {
format!(
"Failed to create memory root for last_dream_at: {:?}",
parent
)
})?;
}
}
fs::write(self.last_dream_path(), now.to_string()).with_context(|| {
format!(
"Failed to write last_dream_at to {:?}",
self.last_dream_path()
)
})?;
Ok(())
}
fn count_sessions_since_last_dream(&self, history: &super::HistoryStore) -> usize {
let last_dream = self.read_last_dream_at();
let sessions = history.list_sessions().unwrap_or_default();
if last_dream == 0 {
return sessions.len();
}
let last_dream_iso = super::auto_memory::now_iso();
sessions
.iter()
.filter(|s| {
s.started_at.as_str() > last_dream_iso.as_str() || s.started_at.is_empty()
})
.count()
}
fn try_acquire_lock(&self) -> bool {
let lock_path = self.lock_path();
if is_lock_held(&lock_path) {
return false;
}
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)
{
Ok(_) => {
mark_lock_held(lock_path.clone());
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let _ = fs::write(&lock_path, now.to_string());
true
}
Err(_) => {
if let Ok(content) = fs::read_to_string(&lock_path) {
if let Ok(lock_time) = content.trim().parse::<u64>() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if now.saturating_sub(lock_time) > 3600 {
let _ = fs::remove_file(&lock_path);
if fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)
.is_ok()
{
mark_lock_held(lock_path.clone());
let _ = fs::write(&lock_path, now.to_string());
return true;
}
}
}
}
false
}
}
}
fn release_lock(&self) {
let lock_path = self.lock_path();
mark_lock_released(&lock_path);
let _ = fs::remove_file(&lock_path);
}
pub fn should_dream(&self, history: &super::HistoryStore) -> bool {
let last_dream = self.read_last_dream_at();
if last_dream > 0 {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let elapsed_hours = now.saturating_sub(last_dream) / 3600;
if elapsed_hours < self.dream_interval_hours {
return false;
}
}
let session_count = self.count_sessions_since_last_dream(history);
if session_count < self.min_sessions {
return false;
}
if !self.try_acquire_lock() {
tracing::debug!("auto-dream skipped: lock held by another process");
return false;
}
true
}
pub fn mark_completed(&self) {
if let Err(e) = self.write_last_dream_at() {
tracing::warn!("Failed to write last_dream_at: {}", e);
}
self.release_lock();
}
pub fn release(&self) {
self.release_lock();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn test_state(dir: &Path) -> AutoDreamState {
AutoDreamState::new(dir.to_path_buf())
}
#[test]
fn test_last_dream_persistence() {
let tmp = tempfile::tempdir().expect("tempdir");
let state = test_state(tmp.path());
assert_eq!(state.read_last_dream_at(), 0);
state.write_last_dream_at().expect("write");
let first = state.read_last_dream_at();
assert!(first > 0);
assert_eq!(state.read_last_dream_at(), first);
}
#[test]
fn test_lock_acquire_and_release() {
let tmp = tempfile::tempdir().expect("tempdir");
let state = test_state(tmp.path());
assert!(state.try_acquire_lock());
assert!(state.lock_path().exists());
assert!(!state.try_acquire_lock());
state.release_lock();
assert!(!state.lock_path().exists());
assert!(state.try_acquire_lock());
state.release_lock();
}
#[test]
fn test_should_dream_never_dreamed() {
let tmp = tempfile::tempdir().expect("tempdir");
let state = test_state(tmp.path());
let db_path = tmp.path().join("history.db");
let history = super::super::HistoryStore::new(&db_path).expect("history");
assert!(!state.should_dream(&history));
}
#[test]
fn test_mark_completed() {
let tmp = tempfile::tempdir().expect("tempdir");
let state = test_state(tmp.path());
assert!(state.try_acquire_lock());
state.mark_completed();
assert!(!state.lock_path().exists());
assert!(state.read_last_dream_at() > 0);
}
}