use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::sync::Arc;
use dashmap::DashMap;
use parking_lot::{Mutex, RwLock};
use trusty_mpm_core::agent::Delegation;
use trusty_mpm_core::circuit::{CircuitBreaker, CircuitConfig};
use trusty_mpm_core::deterministic_overseer::DeterministicOverseer;
use trusty_mpm_core::hook::HookEventRecord;
use trusty_mpm_core::memory::{MemoryConfig, MemoryPressure, MemoryUsage};
use trusty_mpm_core::overseer::Overseer;
use trusty_mpm_core::overseer_config::OverseerConfig;
use trusty_mpm_core::paths::FrameworkPaths;
use trusty_mpm_core::project::ProjectInfo;
use trusty_mpm_core::session::{Session, SessionId};
use crate::audit::AuditLogger;
use crate::optimizer::OptimizerConfig;
use crate::tmux::TmuxDriver;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ReapResult {
pub reaped: usize,
pub stopped: usize,
}
pub const HOOK_HISTORY_LIMIT: usize = 1024;
pub const PAIR_CODE_TTL: std::time::Duration = std::time::Duration::from_secs(300);
#[derive(Debug)]
pub struct DaemonState {
sessions: DashMap<SessionId, Session>,
delegations: DashMap<uuid::Uuid, Delegation>,
breakers: DashMap<String, CircuitBreaker>,
memory: DashMap<SessionId, MemoryUsage>,
hook_history: Mutex<VecDeque<HookEventRecord>>,
pub memory_config: MemoryConfig,
pub circuit_config: CircuitConfig,
trusty_addrs: Mutex<Option<crate::discover::TrustyAddrs>>,
optimizer: Arc<parking_lot::RwLock<OptimizerConfig>>,
projects: Arc<RwLock<HashMap<PathBuf, ProjectInfo>>>,
overseer: Arc<dyn Overseer>,
overseer_handler: String,
llm: Option<Arc<crate::llm_overseer::LlmOverseer>>,
audit: Arc<AuditLogger>,
paired_chat_id: Mutex<Option<i64>>,
pair_code: Mutex<Option<(String, std::time::Instant)>>,
framework_root: PathBuf,
}
impl Default for DaemonState {
fn default() -> Self {
Self::new()
}
}
fn load_optimizer_config() -> OptimizerConfig {
let path = FrameworkPaths::default().optimizer_config();
match OptimizerConfig::load_from_file(&path) {
Ok(cfg) => cfg,
Err(e) => {
tracing::warn!(
"failed to load optimizer config from {}: {e}; using defaults",
path.display()
);
OptimizerConfig::default()
}
}
}
fn load_overseer() -> OverseerBuild {
let path = FrameworkPaths::default().overseer_config();
build_overseer(OverseerConfig::load_from(&path))
}
struct OverseerBuild {
overseer: Arc<dyn Overseer>,
handler: String,
llm: Option<Arc<crate::llm_overseer::LlmOverseer>>,
}
fn build_overseer(config: OverseerConfig) -> OverseerBuild {
let deterministic = DeterministicOverseer::new(config.clone());
if config.llm.enabled {
let llm = Arc::new(crate::llm_overseer::LlmOverseer::new(
config.llm.model.clone(),
&config.llm.api_key_env,
));
if llm.is_enabled() {
tracing::info!(
"LLM overseer active (model {}); composing with deterministic rules",
config.llm.model
);
let composite_llm = crate::llm_overseer::LlmOverseer::new(
config.llm.model.clone(),
&config.llm.api_key_env,
);
let composite = crate::overseer_compose::CompositeOverseer::new(
Box::new(deterministic),
Box::new(composite_llm),
);
return OverseerBuild {
overseer: Arc::new(composite),
handler: "composite-llm".to_string(),
llm: Some(llm),
};
}
tracing::warn!(
"[llm] enabled but no API key in ${}; falling back to deterministic overseer",
config.llm.api_key_env
);
}
OverseerBuild {
overseer: Arc::new(deterministic),
handler: "deterministic".to_string(),
llm: None,
}
}
fn logs_dir() -> PathBuf {
FrameworkPaths::default().root.join("logs")
}
impl DaemonState {
pub fn new() -> Self {
let optimizer = load_optimizer_config();
let build = load_overseer();
let framework_root = FrameworkPaths::default().root;
let paired = crate::pairing_store::load(&framework_root).map(|r| r.chat_id);
if let Some(chat_id) = paired {
tracing::info!("restored persisted Telegram pairing (chat {chat_id})");
}
Self {
sessions: DashMap::new(),
delegations: DashMap::new(),
breakers: DashMap::new(),
memory: DashMap::new(),
hook_history: Mutex::new(VecDeque::with_capacity(HOOK_HISTORY_LIMIT)),
memory_config: MemoryConfig::default(),
circuit_config: CircuitConfig::default(),
trusty_addrs: Mutex::new(None),
optimizer: Arc::new(parking_lot::RwLock::new(optimizer)),
projects: Arc::new(RwLock::new(HashMap::new())),
overseer: build.overseer,
overseer_handler: build.handler,
llm: build.llm,
audit: Arc::new(AuditLogger::new(&logs_dir())),
paired_chat_id: Mutex::new(paired),
pair_code: Mutex::new(None),
framework_root,
}
}
pub fn shared() -> Arc<Self> {
Arc::new(Self::new())
}
#[doc(hidden)]
pub fn with_root(root: PathBuf) -> Self {
let mut state = Self::new();
let paired = crate::pairing_store::load(&root).map(|r| r.chat_id);
*state.paired_chat_id.lock() = paired;
state.framework_root = root;
state
}
pub fn with_paths(paths: &FrameworkPaths) -> Self {
let optimizer = match OptimizerConfig::load_from_file(&paths.optimizer_config()) {
Ok(cfg) => cfg,
Err(e) => {
tracing::warn!("failed to load optimizer config: {e}; using defaults");
OptimizerConfig::default()
}
};
let overseer_cfg = OverseerConfig::load_from(&paths.overseer_config());
let build = build_overseer(overseer_cfg);
let framework_root = paths.root.clone();
let paired = crate::pairing_store::load(&framework_root).map(|r| r.chat_id);
Self {
sessions: DashMap::new(),
delegations: DashMap::new(),
breakers: DashMap::new(),
memory: DashMap::new(),
hook_history: Mutex::new(VecDeque::with_capacity(HOOK_HISTORY_LIMIT)),
memory_config: MemoryConfig::default(),
circuit_config: CircuitConfig::default(),
trusty_addrs: Mutex::new(None),
optimizer: Arc::new(parking_lot::RwLock::new(optimizer)),
projects: Arc::new(RwLock::new(HashMap::new())),
overseer: build.overseer,
overseer_handler: build.handler,
llm: build.llm,
audit: Arc::new(AuditLogger::new(&paths.root.join("logs"))),
paired_chat_id: Mutex::new(paired),
pair_code: Mutex::new(None),
framework_root,
}
}
pub fn generate_pair_code(&self) -> String {
let code: String = uuid::Uuid::new_v4()
.simple()
.to_string()
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.take(6)
.collect::<String>()
.to_uppercase();
*self.pair_code.lock() = Some((code.clone(), std::time::Instant::now()));
code
}
pub fn confirm_pair_code(&self, code: &str, chat_id: i64) -> bool {
let mut guard = self.pair_code.lock();
let valid = matches!(
guard.as_ref(),
Some((stored, issued))
if stored == code && issued.elapsed() < PAIR_CODE_TTL
);
*guard = None;
if valid {
*self.paired_chat_id.lock() = Some(chat_id);
let record = crate::pairing_store::PairingRecord::new(chat_id);
if let Err(e) = crate::pairing_store::save(&self.framework_root, &record) {
tracing::warn!("failed to persist Telegram pairing: {e}");
}
}
valid
}
pub fn clear_pairing(&self) {
*self.paired_chat_id.lock() = None;
if let Err(e) = crate::pairing_store::clear(&self.framework_root) {
tracing::warn!("failed to delete persisted Telegram pairing: {e}");
}
}
pub fn paired_chat_id(&self) -> Option<i64> {
*self.paired_chat_id.lock()
}
pub fn register_session(&self, session: Session) {
self.sessions.insert(session.id, session);
}
pub fn set_session_pid(&self, id: SessionId, pid: u32) -> bool {
self.update_session(&id, |s| s.pid = Some(pid))
}
pub fn remove_session(&self, id: SessionId) -> Option<Session> {
self.memory.remove(&id);
self.sessions.remove(&id).map(|(_, s)| s)
}
pub fn list_sessions(&self) -> Vec<Session> {
self.sessions.iter().map(|e| e.value().clone()).collect()
}
pub fn session(&self, id: SessionId) -> Option<Session> {
self.sessions.get(&id).map(|e| e.value().clone())
}
pub fn update_session<F>(&self, id: &SessionId, f: F) -> bool
where
F: FnOnce(&mut Session),
{
match self.sessions.get_mut(id) {
Some(mut entry) => {
f(entry.value_mut());
true
}
None => false,
}
}
pub fn list_sessions_for_project(&self, path: &std::path::Path) -> Vec<Session> {
self.sessions
.iter()
.filter(|e| e.value().project_path.as_deref() == Some(path))
.map(|e| e.value().clone())
.collect()
}
pub fn find_session(&self, key: &str) -> Option<Session> {
if let Ok(uuid) = uuid::Uuid::parse_str(key) {
return self.session(SessionId(uuid));
}
self.sessions
.iter()
.find(|e| e.value().tmux_name == key)
.map(|e| e.value().clone())
}
pub fn reap_dead_sessions(&self, driver: &TmuxDriver) -> ReapResult {
let live: std::collections::HashSet<String> = match driver.list_sessions() {
Ok(sessions) => sessions.into_iter().map(|s| s.name).collect(),
Err(e) => {
tracing::warn!("reap skipped — tmux list-sessions failed: {e}");
return ReapResult::default();
}
};
self.reap_against(&live)
}
fn reap_against(&self, live: &std::collections::HashSet<String>) -> ReapResult {
use trusty_mpm_core::session::{SessionHost, SessionStatus};
let mut dead: Vec<SessionId> = Vec::new();
let mut stopped_ids: Vec<SessionId> = Vec::new();
for entry in self.sessions.iter() {
let session = entry.value();
if session.origin != SessionHost::Tmux {
continue;
}
if !live.contains(&session.tmux_name) {
dead.push(*entry.key());
} else if session.status != SessionStatus::Stopped
&& let Some(pid) = session.pid
&& !trusty_mpm_core::process::is_process_alive(pid)
{
stopped_ids.push(*entry.key());
}
}
for id in &dead {
self.remove_session(*id);
}
for id in &stopped_ids {
self.update_session(id, |s| s.status = SessionStatus::Stopped);
}
ReapResult {
reaped: dead.len(),
stopped: stopped_ids.len(),
}
}
pub fn register_project(&self, path: PathBuf) -> ProjectInfo {
let info = ProjectInfo::new(path.clone());
self.projects.write().insert(path, info.clone());
info
}
pub fn list_projects(&self) -> Vec<ProjectInfo> {
self.projects.read().values().cloned().collect()
}
pub fn project(&self, path: &std::path::Path) -> Option<ProjectInfo> {
self.projects.read().get(path).cloned()
}
pub fn upsert_delegation(&self, delegation: Delegation) {
self.delegations.insert(delegation.id.0, delegation);
}
pub fn delegations_for(&self, session: SessionId) -> Vec<Delegation> {
self.delegations
.iter()
.filter(|e| e.value().session == session)
.map(|e| e.value().clone())
.collect()
}
pub fn breaker(&self, agent: &str) -> CircuitBreaker {
self.breakers
.entry(agent.to_string())
.or_insert_with(|| CircuitBreaker::new(self.circuit_config))
.value()
.clone()
}
pub fn record_outcome(&self, agent: &str, success: bool) {
let mut entry = self
.breakers
.entry(agent.to_string())
.or_insert_with(|| CircuitBreaker::new(self.circuit_config));
if success {
entry.record_success();
} else {
entry.record_failure();
}
}
pub fn all_breakers(&self) -> Vec<(String, CircuitBreaker)> {
self.breakers
.iter()
.map(|e| (e.key().clone(), e.value().clone()))
.collect()
}
pub fn record_memory(&self, session: SessionId, usage: MemoryUsage) -> MemoryPressure {
self.memory.insert(session, usage);
usage.pressure(&self.memory_config)
}
pub fn memory_for(&self, session: SessionId) -> Option<MemoryUsage> {
self.memory.get(&session).map(|e| *e.value())
}
pub fn set_trusty_addrs(&self, addrs: crate::discover::TrustyAddrs) {
*self.trusty_addrs.lock() = Some(addrs);
}
#[allow(dead_code)] pub fn trusty_addrs(&self) -> Option<crate::discover::TrustyAddrs> {
self.trusty_addrs.lock().clone()
}
pub fn optimizer_config(&self) -> OptimizerConfig {
self.optimizer.read().clone()
}
pub fn reload_optimizer_config(&self) {
*self.optimizer.write() = load_optimizer_config();
}
pub fn reload_optimizer_config_from(&self, path: &std::path::Path) -> anyhow::Result<()> {
let cfg = OptimizerConfig::load_from_file(path)?;
*self.optimizer.write() = cfg;
Ok(())
}
pub fn overseer(&self) -> Arc<dyn Overseer> {
Arc::clone(&self.overseer)
}
pub fn overseer_handler(&self) -> &str {
&self.overseer_handler
}
pub fn audit(&self) -> Arc<AuditLogger> {
Arc::clone(&self.audit)
}
pub fn llm_overseer(&self) -> Option<Arc<crate::llm_overseer::LlmOverseer>> {
self.llm.clone()
}
pub fn push_hook_event(&self, record: HookEventRecord) {
let mut buf = self.hook_history.lock();
if buf.len() >= HOOK_HISTORY_LIMIT {
buf.pop_front();
}
buf.push_back(record);
}
pub fn recent_hook_events(&self) -> Vec<HookEventRecord> {
self.hook_history.lock().iter().cloned().collect()
}
pub fn hook_events_for(&self, session: SessionId) -> Vec<HookEventRecord> {
self.hook_history
.lock()
.iter()
.filter(|r| r.session == session)
.cloned()
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use trusty_mpm_core::hook::HookEvent;
use trusty_mpm_core::session::{ControlModel, SessionStatus};
fn sample_session() -> Session {
let mut s = Session::new(SessionId::new(), "/tmp/p", ControlModel::Tmux, None);
s.status = SessionStatus::Active;
s
}
#[test]
fn register_and_list_sessions() {
let state = DaemonState::new();
let s = sample_session();
let id = s.id;
state.register_session(s);
assert_eq!(state.list_sessions().len(), 1);
assert!(state.session(id).is_some());
assert!(state.remove_session(id).is_some());
assert!(state.list_sessions().is_empty());
}
#[test]
fn update_session_mutates_existing() {
let state = DaemonState::new();
let s = sample_session();
let id = s.id;
state.register_session(s);
let ran = state.update_session(&id, |session| {
session.status = SessionStatus::Paused;
session.pause_summary = Some("note".to_string());
});
assert!(ran);
let updated = state.session(id).expect("session exists");
assert_eq!(updated.status, SessionStatus::Paused);
assert_eq!(updated.pause_summary.as_deref(), Some("note"));
}
#[test]
fn update_session_missing_is_false() {
let state = DaemonState::new();
let ran = state.update_session(&SessionId::new(), |_| {});
assert!(!ran);
}
#[test]
fn register_and_list_projects() {
let state = DaemonState::new();
assert!(state.list_projects().is_empty());
let info = state.register_project(PathBuf::from("/work/demo"));
assert_eq!(info.name, "demo");
assert_eq!(state.list_projects().len(), 1);
state.register_project(PathBuf::from("/work/demo"));
assert_eq!(state.list_projects().len(), 1);
state.register_project(PathBuf::from("/work/other"));
assert_eq!(state.list_projects().len(), 2);
}
#[test]
fn project_lookup_by_path() {
let state = DaemonState::new();
state.register_project(PathBuf::from("/work/demo"));
assert!(state.project(std::path::Path::new("/work/demo")).is_some());
assert!(
state
.project(std::path::Path::new("/work/missing"))
.is_none()
);
}
#[test]
fn list_sessions_for_project_filters() {
let state = DaemonState::new();
let mut in_proj = sample_session();
in_proj.project_path = Some(PathBuf::from("/work/demo"));
let mut other_proj = sample_session();
other_proj.project_path = Some(PathBuf::from("/work/other"));
let no_proj = sample_session();
state.register_session(in_proj.clone());
state.register_session(other_proj);
state.register_session(no_proj);
let listed = state.list_sessions_for_project(std::path::Path::new("/work/demo"));
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, in_proj.id);
}
#[test]
fn find_session_by_id_or_name() {
let state = DaemonState::new();
let s = sample_session();
let id = s.id;
let name = s.tmux_name.clone();
state.register_session(s);
assert!(state.find_session(&id.0.to_string()).is_some());
assert!(state.find_session(&name).is_some());
assert!(state.find_session("tmpm-no-such-name").is_none());
assert!(
state
.find_session(&SessionId::new().0.to_string())
.is_none()
);
}
#[test]
fn breaker_tracks_outcomes() {
let state = DaemonState::new();
for _ in 0..3 {
state.record_outcome("research", false);
}
let cb = state.breaker("research");
assert!(!cb.allows_delegation());
state.record_outcome("research", true);
assert_eq!(state.breaker("research").consecutive_failures, 0);
}
#[test]
fn memory_pressure_is_classified() {
let state = DaemonState::new();
let id = SessionId::new();
let pressure = state.record_memory(
id,
MemoryUsage {
used_tokens: 900,
window_tokens: 1000,
},
);
assert_eq!(pressure, MemoryPressure::Compact);
assert!(state.memory_for(id).is_some());
}
#[test]
fn trusty_addrs_round_trip() {
let state = DaemonState::new();
assert!(state.trusty_addrs().is_none());
let addrs = crate::discover::TrustyAddrs {
memory: "127.0.0.1:3038".parse().unwrap(),
search: "127.0.0.1:7878".parse().unwrap(),
};
state.set_trusty_addrs(addrs);
let got = state.trusty_addrs().expect("addrs stored");
assert_eq!(got.memory, "127.0.0.1:3038".parse().unwrap());
assert_eq!(got.search, "127.0.0.1:7878".parse().unwrap());
}
#[test]
fn reap_dead_sessions() {
let state = DaemonState::new();
let alive_a = sample_session();
let alive_b = sample_session();
let dead = sample_session();
let (id_a, id_b, id_dead) = (alive_a.id, alive_b.id, dead.id);
state.register_session(alive_a.clone());
state.register_session(alive_b.clone());
state.register_session(dead);
assert_eq!(state.list_sessions().len(), 3);
let live: std::collections::HashSet<String> =
[alive_a.tmux_name.clone(), alive_b.tmux_name.clone()]
.into_iter()
.collect();
let result = state.reap_against(&live);
assert_eq!(result.reaped, 1);
assert_eq!(result.stopped, 0);
assert!(state.session(id_a).is_some());
assert!(state.session(id_b).is_some());
assert!(state.session(id_dead).is_none());
assert_eq!(state.reap_against(&live), ReapResult::default());
}
#[test]
fn reap_against_empty_live_removes_all_tmux_sessions() {
let state = DaemonState::new();
state.register_session(sample_session());
state.register_session(sample_session());
let result = state.reap_against(&std::collections::HashSet::new());
assert_eq!(result.reaped, 2);
assert!(state.list_sessions().is_empty());
}
#[test]
fn reap_keeps_native_sessions() {
let state = DaemonState::new();
let mut native = sample_session();
native.origin = trusty_mpm_core::session::SessionHost::Native;
native.pid = Some(9999);
let native_id = native.id;
let tmux = sample_session();
let tmux_id = tmux.id;
state.register_session(native);
state.register_session(tmux);
let result = state.reap_against(&std::collections::HashSet::new());
assert_eq!(result.reaped, 1);
assert!(state.session(native_id).is_some());
assert!(state.session(tmux_id).is_none());
}
#[test]
fn set_session_pid_updates_field() {
let state = DaemonState::new();
let s = sample_session();
let id = s.id;
state.register_session(s);
assert_eq!(state.session(id).unwrap().pid, None);
assert!(state.set_session_pid(id, 4242));
assert_eq!(state.session(id).unwrap().pid, Some(4242));
assert!(!state.set_session_pid(SessionId::new(), 1));
}
#[test]
fn reap_marks_stopped_when_pid_dead() {
let state = DaemonState::new();
let mut session = sample_session();
session.pid = Some(u32::MAX);
let id = session.id;
let tmux_name = session.tmux_name.clone();
state.register_session(session);
let live: std::collections::HashSet<String> = [tmux_name].into_iter().collect();
let result = state.reap_against(&live);
assert_eq!(result.reaped, 0);
assert_eq!(result.stopped, 1);
let after = state.session(id).expect("session is kept, not removed");
assert_eq!(after.status, SessionStatus::Stopped);
}
#[test]
fn new_reads_default_when_optimizer_file_missing() {
let state = DaemonState::new();
assert_eq!(
state.optimizer_config().default_level,
trusty_mpm_core::compress::CompressionLevel::Trim
);
}
#[test]
fn reload_optimizer_config_picks_up_file_changes() {
use std::io::Write;
let state = DaemonState::new();
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("optimizer.toml");
let mut file = std::fs::File::create(&path).expect("create file");
writeln!(file, "[default]\nlevel = \"caveman\"").expect("write file");
state
.reload_optimizer_config_from(&path)
.expect("reload succeeds");
assert_eq!(
state.optimizer_config().default_level,
trusty_mpm_core::compress::CompressionLevel::Caveman
);
state
.reload_optimizer_config_from(&dir.path().join("absent.toml"))
.expect("missing file is not an error");
assert_eq!(
state.optimizer_config().default_level,
trusty_mpm_core::compress::CompressionLevel::Trim
);
}
#[test]
fn new_overseer_is_disabled_when_file_missing() {
let state = DaemonState::new();
assert!(!state.overseer().is_enabled());
}
#[test]
fn overseer_is_deterministic_without_llm() {
let cfg = OverseerConfig::default();
let build = build_overseer(cfg);
assert!(!build.overseer.is_enabled());
assert_eq!(build.handler, "deterministic");
assert!(build.llm.is_none());
}
#[test]
fn overseer_falls_back_when_llm_key_missing() {
let mut cfg = OverseerConfig::default();
cfg.llm.enabled = true;
cfg.llm.api_key_env = "TRUSTY_MPM_DEFINITELY_NOT_SET".to_string(); let build = build_overseer(cfg);
assert!(!build.overseer.is_enabled());
assert_eq!(build.handler, "deterministic");
assert!(build.llm.is_none());
}
#[test]
fn llm_overseer_is_none_without_key() {
let state = DaemonState::new();
assert!(state.llm_overseer().is_none());
}
#[test]
fn overseer_handler_reports_strategy() {
let state = DaemonState::new();
assert_eq!(state.overseer_handler(), "deterministic");
}
#[test]
fn overseer_is_accessible() {
let state = DaemonState::new();
let overseer = state.overseer();
assert!(!overseer.is_enabled());
}
#[test]
fn audit_logger_is_accessible() {
let state = DaemonState::new();
let audit = state.audit();
assert_eq!(
audit.path().extension().and_then(|e| e.to_str()),
Some("jsonl")
);
}
#[test]
fn hook_history_is_bounded() {
let state = DaemonState::new();
let id = SessionId::new();
for _ in 0..(HOOK_HISTORY_LIMIT + 50) {
state.push_hook_event(HookEventRecord::now(
id,
HookEvent::PreToolUse,
serde_json::Value::Null,
));
}
assert_eq!(state.recent_hook_events().len(), HOOK_HISTORY_LIMIT);
assert_eq!(state.hook_events_for(id).len(), HOOK_HISTORY_LIMIT);
}
#[test]
fn pairing_round_trip() {
let dir = tempfile::tempdir().expect("temp dir");
let state = DaemonState::with_root(dir.path().to_path_buf());
assert_eq!(state.paired_chat_id(), None);
let code = state.generate_pair_code();
assert_eq!(code.len(), 6);
assert!(code.chars().all(|c| c.is_ascii_alphanumeric()));
assert!(state.confirm_pair_code(&code, 12345678));
assert_eq!(state.paired_chat_id(), Some(12345678));
assert!(!state.confirm_pair_code(&code, 999));
}
#[test]
fn wrong_pair_code_is_rejected() {
let dir = tempfile::tempdir().expect("temp dir");
let state = DaemonState::with_root(dir.path().to_path_buf());
let _code = state.generate_pair_code();
assert!(!state.confirm_pair_code("ZZZZZZ", 12345678));
assert_eq!(state.paired_chat_id(), None);
}
#[test]
fn pairing_persists_to_disk() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().to_path_buf();
let state = DaemonState::with_root(root.clone());
let code = state.generate_pair_code();
assert!(state.confirm_pair_code(&code, 555));
assert_eq!(
crate::pairing_store::load(&root).map(|r| r.chat_id),
Some(555)
);
let restored = DaemonState::with_root(root);
assert_eq!(restored.paired_chat_id(), Some(555));
}
#[test]
fn pairing_reset_clears_disk() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().to_path_buf();
let state = DaemonState::with_root(root.clone());
let code = state.generate_pair_code();
assert!(state.confirm_pair_code(&code, 777));
state.clear_pairing();
assert_eq!(state.paired_chat_id(), None);
assert!(crate::pairing_store::load(&root).is_none());
}
}