use std::path::{Path, PathBuf};
pub const TURNS_PER_FIRE: i64 = 20;
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct SessionMineState {
#[serde(default)]
pub last_fire_ts: i64,
#[serde(default)]
pub turns_since_fire: i64,
#[serde(default)]
pub last_session_id: String,
}
pub fn should_trigger_now(state_path: &Path, turn_count: i64) -> bool {
should_trigger_now_with_force(state_path, turn_count, false)
}
pub fn should_trigger_now_with_force(
state_path: &Path,
turn_count: i64,
force_session_end: bool,
) -> bool {
let mut state = read_state(state_path).unwrap_or_default();
state.turns_since_fire = turn_count.max(state.turns_since_fire);
let fire = force_session_end || state.turns_since_fire >= TURNS_PER_FIRE;
if fire {
state.last_fire_ts = now_unix_ms();
state.turns_since_fire = 0;
}
let _ = write_state(state_path, &state);
fire
}
pub fn state_file_for_cwd() -> Result<PathBuf, String> {
let root = difflore_core::infra::db::current_project_root();
let hash = difflore_core::infra::db::project_hash_from_root(&root);
let mut path = difflore_core::infra::db::project_index_dir(&hash);
path.push("session-mine-state.json");
Ok(path)
}
fn read_state(path: &Path) -> Option<SessionMineState> {
let body = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&body).ok()
}
fn write_state(path: &Path, state: &SessionMineState) -> Result<(), std::io::Error> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body =
serde_json::to_string_pretty(state).map_err(|e| std::io::Error::other(e.to_string()))?;
std::fs::write(path, body)
}
fn now_unix_ms() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_state_path() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("session-mine-state.json");
(dir, path)
}
#[test]
fn first_call_under_threshold_does_not_fire() {
let (_dir, path) = tmp_state_path();
assert!(!should_trigger_now(&path, 5));
let state = read_state(&path).expect("state must persist");
assert_eq!(state.turns_since_fire, 5);
assert_eq!(state.last_fire_ts, 0);
}
#[test]
fn reaching_turn_threshold_fires_and_resets() {
let (_dir, path) = tmp_state_path();
assert!(should_trigger_now(&path, TURNS_PER_FIRE));
let state = read_state(&path).expect("state must persist");
assert_eq!(state.turns_since_fire, 0);
assert!(state.last_fire_ts > 0);
}
#[test]
fn force_session_end_fires_regardless_of_turn_count() {
let (_dir, path) = tmp_state_path();
let fired = should_trigger_now_with_force(&path, 1, true);
assert!(fired, "session-end must short-circuit the turn watermark");
let state = read_state(&path).expect("state must persist");
assert_eq!(state.turns_since_fire, 0);
}
#[test]
fn malformed_state_file_is_treated_as_fresh() {
let (_dir, path) = tmp_state_path();
std::fs::write(&path, "{ not json").unwrap();
assert!(!should_trigger_now(&path, 1));
}
#[test]
fn turn_count_below_persisted_value_does_not_decrement() {
let (_dir, path) = tmp_state_path();
let _ = should_trigger_now(&path, 5);
let _ = should_trigger_now(&path, 1);
let state = read_state(&path).expect("state must persist");
assert_eq!(
state.turns_since_fire, 5,
"watermark must monotonically rise inside a session"
);
}
}