1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::error::TelegramResult;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct TelegramState {
12 pub chat_id: Option<i64>,
14
15 pub last_seen: Option<DateTime<Utc>>,
17
18 #[serde(default)]
20 pub last_update_id: Option<i32>,
21
22 #[serde(default)]
24 pub pending_questions: HashMap<String, PendingQuestion>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct PendingQuestion {
30 pub asked_at: DateTime<Utc>,
32
33 pub message_id: i32,
35}
36
37pub struct StateManager {
39 path: PathBuf,
40}
41
42impl StateManager {
43 pub fn new(path: impl Into<PathBuf>) -> Self {
45 Self { path: path.into() }
46 }
47
48 pub fn load(&self) -> TelegramResult<Option<TelegramState>> {
50 if !self.path.exists() {
51 return Ok(None);
52 }
53 let contents = std::fs::read_to_string(&self.path)?;
54 let state: TelegramState = serde_json::from_str(&contents)?;
55 Ok(Some(state))
56 }
57
58 pub fn save(&self, state: &TelegramState) -> TelegramResult<()> {
60 let json = serde_json::to_string_pretty(state)?;
61 let tmp_path = self.path.with_extension("json.tmp");
62
63 if let Some(parent) = self.path.parent() {
64 std::fs::create_dir_all(parent)?;
65 }
66
67 std::fs::write(&tmp_path, &json)?;
68 std::fs::rename(&tmp_path, &self.path)?;
69 Ok(())
70 }
71
72 pub fn load_or_default(&self) -> TelegramResult<TelegramState> {
74 Ok(self.load()?.unwrap_or_else(|| TelegramState {
75 chat_id: None,
76 last_seen: None,
77 last_update_id: None,
78 pending_questions: HashMap::new(),
79 }))
80 }
81
82 pub fn add_pending_question(
84 &self,
85 state: &mut TelegramState,
86 loop_id: &str,
87 message_id: i32,
88 ) -> TelegramResult<()> {
89 state.pending_questions.insert(
90 loop_id.to_string(),
91 PendingQuestion {
92 asked_at: Utc::now(),
93 message_id,
94 },
95 );
96 self.save(state)
97 }
98
99 pub fn remove_pending_question(
101 &self,
102 state: &mut TelegramState,
103 loop_id: &str,
104 ) -> TelegramResult<()> {
105 state.pending_questions.remove(loop_id);
106 self.save(state)
107 }
108
109 pub fn get_loop_for_reply(
111 &self,
112 state: &TelegramState,
113 reply_message_id: i32,
114 ) -> Option<String> {
115 state
116 .pending_questions
117 .iter()
118 .find(|(_, q)| q.message_id == reply_message_id)
119 .map(|(loop_id, _)| loop_id.clone())
120 }
121
122 pub fn path(&self) -> &Path {
124 &self.path
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use tempfile::TempDir;
132
133 fn test_manager() -> (StateManager, TempDir) {
134 let dir = TempDir::new().unwrap();
135 let path = dir.path().join("telegram-state.json");
136 (StateManager::new(path), dir)
137 }
138
139 #[test]
140 fn load_missing_file_returns_none() {
141 let (mgr, _dir) = test_manager();
142 assert!(mgr.load().unwrap().is_none());
143 }
144
145 #[test]
146 fn save_and_load_round_trip() {
147 let (mgr, _dir) = test_manager();
148 let state = TelegramState {
149 chat_id: Some(123_456),
150 last_seen: Some(Utc::now()),
151 last_update_id: Some(101),
152 pending_questions: HashMap::new(),
153 };
154 mgr.save(&state).unwrap();
155
156 let loaded = mgr.load().unwrap().unwrap();
157 assert_eq!(loaded.chat_id, Some(123_456));
158 assert_eq!(loaded.last_update_id, Some(101));
159 }
160
161 #[test]
162 fn corrupted_json_returns_error() {
163 let (mgr, _dir) = test_manager();
164 std::fs::write(mgr.path(), "not json").unwrap();
165 assert!(mgr.load().is_err());
166 }
167
168 #[test]
169 fn pending_question_tracking() {
170 let (mgr, _dir) = test_manager();
171 let mut state = mgr.load_or_default().unwrap();
172
173 mgr.add_pending_question(&mut state, "main", 42).unwrap();
174 assert!(state.pending_questions.contains_key("main"));
175 assert_eq!(state.pending_questions["main"].message_id, 42);
176
177 mgr.remove_pending_question(&mut state, "main").unwrap();
178 assert!(!state.pending_questions.contains_key("main"));
179 }
180
181 #[test]
182 fn reply_routing_lookup() {
183 let (mgr, _dir) = test_manager();
184 let mut state = mgr.load_or_default().unwrap();
185
186 mgr.add_pending_question(&mut state, "main", 10).unwrap();
187 mgr.add_pending_question(&mut state, "feature-auth", 20)
188 .unwrap();
189
190 assert_eq!(mgr.get_loop_for_reply(&state, 10), Some("main".to_string()));
191 assert_eq!(
192 mgr.get_loop_for_reply(&state, 20),
193 Some("feature-auth".to_string())
194 );
195 assert_eq!(mgr.get_loop_for_reply(&state, 99), None);
196 }
197}