1use anyhow::{Context, Result};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::fs::{File, OpenOptions};
10use std::io::{Read, Seek, SeekFrom, Write};
11use std::path::PathBuf;
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15pub struct SessionConfig {
16 pub count: u32,
18 pub last_updated: DateTime<Utc>,
20 pub sessions: Vec<String>,
22}
23
24impl Default for SessionConfig {
25 fn default() -> Self {
26 Self {
27 count: 0,
28 last_updated: Utc::now(),
29 sessions: Vec::new(),
30 }
31 }
32}
33
34impl SessionConfig {
35 pub fn new() -> Self {
37 Self::default()
38 }
39
40 pub fn increment(&mut self) {
42 self.count = self.count.saturating_add(1);
43 self.last_updated = Utc::now();
44 }
45
46 pub fn decrement(&mut self) {
48 self.count = self.count.saturating_sub(1);
49 self.last_updated = Utc::now();
50 }
51
52 pub fn add_session(&mut self, session_id: String) {
54 if !self.sessions.contains(&session_id) {
55 self.sessions.push(session_id);
56 }
57 }
58
59 pub fn remove_session(&mut self, session_id: &str) {
61 self.sessions.retain(|id| id != session_id);
62 }
63}
64
65pub fn get_config_path() -> Result<PathBuf> {
67 let home_dir = dirs::home_dir().context("Failed to get home directory")?;
68 Ok(home_dir.join(".sessions.json"))
69}
70
71pub fn read_config() -> Result<SessionConfig> {
73 let config_path = get_config_path()?;
74
75 if !config_path.exists() {
77 return Ok(SessionConfig::default());
78 }
79
80 let mut file = File::open(&config_path)
81 .with_context(|| format!("Failed to open config file: {}", config_path.display()))?;
82
83 #[cfg(unix)]
85 {
86 nix_flock(&file, nix::fcntl::FlockArg::LockShared)?;
88 }
89
90 let mut contents = String::new();
91 file.read_to_string(&mut contents)
92 .context("Failed to read config file")?;
93
94 if contents.trim().is_empty() {
95 return Ok(SessionConfig::default());
96 }
97
98 let config: SessionConfig =
99 serde_json::from_str(&contents).context("Failed to parse config JSON")?;
100
101 Ok(config)
102}
103
104pub fn write_config(config: &SessionConfig) -> Result<()> {
106 let config_path = get_config_path()?;
107
108 if let Some(parent) = config_path.parent() {
110 std::fs::create_dir_all(parent).context("Failed to create config directory")?;
111 }
112
113 let mut file = OpenOptions::new()
115 .write(true)
116 .create(true)
117 .truncate(true)
118 .open(&config_path)
119 .with_context(|| {
120 format!(
121 "Failed to open config file for writing: {}",
122 config_path.display()
123 )
124 })?;
125
126 #[cfg(unix)]
128 {
129 nix_flock(&file, nix::fcntl::FlockArg::LockExclusive)?;
130 }
131
132 let json_content =
133 serde_json::to_string_pretty(config).context("Failed to serialize config to JSON")?;
134
135 file.write_all(json_content.as_bytes())
136 .context("Failed to write config to file")?;
137
138 file.sync_all().context("Failed to sync file to disk")?;
139
140 Ok(())
141}
142
143pub fn update_config<F>(mut updater: F) -> Result<SessionConfig>
145where
146 F: FnMut(&mut SessionConfig),
147{
148 let config_path = get_config_path()?;
149
150 if let Some(parent) = config_path.parent() {
152 std::fs::create_dir_all(parent).context("Failed to create config directory")?;
153 }
154
155 let mut file = OpenOptions::new()
157 .read(true)
158 .write(true)
159 .create(true)
160 .truncate(false) .open(&config_path)
162 .with_context(|| format!("Failed to open config file: {}", config_path.display()))?;
163
164 #[cfg(unix)]
166 {
167 nix_flock(&file, nix::fcntl::FlockArg::LockExclusive)?;
168 }
169
170 let mut contents = String::new();
172 file.read_to_string(&mut contents)
173 .context("Failed to read config file")?;
174
175 let mut config = if contents.trim().is_empty() {
176 SessionConfig::default()
177 } else {
178 serde_json::from_str(&contents).context("Failed to parse config JSON")?
179 };
180
181 updater(&mut config);
183
184 file.seek(SeekFrom::Start(0))
186 .context("Failed to seek to start of file")?;
187
188 file.set_len(0).context("Failed to truncate file")?;
189
190 let json_content =
191 serde_json::to_string_pretty(&config).context("Failed to serialize config to JSON")?;
192
193 file.write_all(json_content.as_bytes())
194 .context("Failed to write config to file")?;
195
196 file.sync_all().context("Failed to sync file to disk")?;
197
198 Ok(config)
199}
200
201#[cfg(unix)]
202fn nix_flock(file: &File, arg: nix::fcntl::FlockArg) -> Result<()> {
203 use std::os::unix::io::AsRawFd;
204 #[allow(deprecated)]
205 {
206 nix::fcntl::flock(file.as_raw_fd(), arg).context("Failed to acquire file lock")?;
207 }
208 Ok(())
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use tempfile::TempDir;
215
216 #[allow(dead_code)] fn setup_test_config_path(temp_dir: &TempDir) -> PathBuf {
218 temp_dir.path().join(".sessions.json")
219 }
220
221 #[test]
222 fn test_session_config_new() {
223 let config = SessionConfig::new();
224 assert_eq!(config.count, 0);
225 assert!(config.sessions.is_empty());
226 }
227
228 #[test]
229 fn test_session_config_increment() {
230 let mut config = SessionConfig::new();
231 let initial_time = config.last_updated;
232
233 std::thread::sleep(std::time::Duration::from_millis(1));
235
236 config.increment();
237 assert_eq!(config.count, 1);
238 assert!(config.last_updated > initial_time);
239
240 config.increment();
241 assert_eq!(config.count, 2);
242 }
243
244 #[test]
245 fn test_session_config_decrement() {
246 let mut config = SessionConfig::new();
247 config.count = 2;
248
249 config.decrement();
250 assert_eq!(config.count, 1);
251
252 config.decrement();
253 assert_eq!(config.count, 0);
254
255 config.decrement();
257 assert_eq!(config.count, 0);
258 }
259
260 #[test]
261 fn test_session_config_add_remove_session() {
262 let mut config = SessionConfig::new();
263
264 config.add_session("session-1".to_string());
265 assert_eq!(config.sessions.len(), 1);
266 assert!(config.sessions.contains(&"session-1".to_string()));
267
268 config.add_session("session-1".to_string());
270 assert_eq!(config.sessions.len(), 1);
271
272 config.add_session("session-2".to_string());
273 assert_eq!(config.sessions.len(), 2);
274
275 config.remove_session("session-1");
276 assert_eq!(config.sessions.len(), 1);
277 assert!(!config.sessions.contains(&"session-1".to_string()));
278 assert!(config.sessions.contains(&"session-2".to_string()));
279 }
280
281 #[test]
282 fn test_config_serialization() {
283 let config = SessionConfig {
284 count: 5,
285 last_updated: DateTime::parse_from_rfc3339("2025-01-12T10:30:00Z")
286 .unwrap()
287 .with_timezone(&Utc),
288 sessions: vec!["session-1".to_string(), "session-2".to_string()],
289 };
290
291 let json = serde_json::to_string(&config).unwrap();
292 let deserialized: SessionConfig = serde_json::from_str(&json).unwrap();
293
294 assert_eq!(config, deserialized);
295 }
296
297 #[test]
298 fn test_read_nonexistent_config() {
299 let config = SessionConfig::default();
303 assert_eq!(config.count, 0);
304 assert!(config.sessions.is_empty());
305 }
306}