1use chrono::{DateTime, Duration, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fs;
5use std::path::PathBuf;
6
7use crate::error::{NdsError, Result};
8use crate::session::Session;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub enum SessionEvent {
12 Created,
13 Attached,
14 Detached,
15 Killed,
16 Crashed,
17 Renamed { from: Option<String>, to: String },
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct HistoryEntry {
22 pub session_id: String,
23 pub session_name: Option<String>,
24 pub event: SessionEvent,
25 pub timestamp: DateTime<Utc>,
26 pub pid: i32,
27 pub shell: String,
28 pub working_dir: String,
29 pub duration_seconds: Option<i64>, }
31
32#[derive(Debug, Serialize, Deserialize)]
34pub struct SessionHistoryFile {
35 pub session_id: String,
36 pub created_at: DateTime<Utc>,
37 pub entries: Vec<HistoryEntry>,
38}
39
40pub struct SessionHistory;
42
43impl SessionHistory {
44 pub fn history_dir() -> Result<PathBuf> {
46 let dir = if let Ok(nds_home) = std::env::var("NDS_HOME") {
47 PathBuf::from(nds_home).join("history")
48 } else {
49 directories::BaseDirs::new()
50 .ok_or_else(|| {
51 NdsError::DirectoryCreationError("Could not find home directory".to_string())
52 })?
53 .home_dir()
54 .join(".nds")
55 .join("history")
56 };
57
58 if !dir.exists() {
59 fs::create_dir_all(&dir)
60 .map_err(|e| NdsError::DirectoryCreationError(e.to_string()))?;
61 }
62
63 Ok(dir)
64 }
65
66 pub fn active_history_dir() -> Result<PathBuf> {
67 let dir = Self::history_dir()?.join("active");
68 if !dir.exists() {
69 fs::create_dir_all(&dir)
70 .map_err(|e| NdsError::DirectoryCreationError(e.to_string()))?;
71 }
72 Ok(dir)
73 }
74
75 pub fn archived_history_dir() -> Result<PathBuf> {
76 let dir = Self::history_dir()?.join("archived");
77 if !dir.exists() {
78 fs::create_dir_all(&dir)
79 .map_err(|e| NdsError::DirectoryCreationError(e.to_string()))?;
80 }
81 Ok(dir)
82 }
83
84 fn session_history_path(session_id: &str, archived: bool) -> Result<PathBuf> {
86 let dir = if archived {
87 Self::archived_history_dir()?
88 } else {
89 Self::active_history_dir()?
90 };
91 Ok(dir.join(format!("{}.json", session_id)))
92 }
93
94 pub fn load_session_history(session_id: &str) -> Result<SessionHistoryFile> {
96 let active_path = Self::session_history_path(session_id, false)?;
98 let archived_path = Self::session_history_path(session_id, true)?;
99
100 let path = if active_path.exists() {
101 active_path
102 } else if archived_path.exists() {
103 archived_path
104 } else {
105 let history = SessionHistoryFile {
107 session_id: session_id.to_string(),
108 created_at: Utc::now(),
109 entries: Vec::new(),
110 };
111 let json = serde_json::to_string_pretty(&history)?;
112 fs::write(&active_path, json)?;
113 return Ok(history);
114 };
115
116 let content = fs::read_to_string(&path)?;
117 let history: SessionHistoryFile = serde_json::from_str(&content)?;
118 Ok(history)
119 }
120
121 fn save_session_history(history: &SessionHistoryFile, archived: bool) -> Result<()> {
123 let path = Self::session_history_path(&history.session_id, archived)?;
124 let json = serde_json::to_string_pretty(history)?;
125 fs::write(path, json)?;
126 Ok(())
127 }
128
129 fn add_entry_to_session(session_id: &str, entry: HistoryEntry) -> Result<()> {
131 let mut history = Self::load_session_history(session_id)?;
132 history.entries.push(entry);
133
134 let should_archive = history
136 .entries
137 .iter()
138 .any(|e| matches!(e.event, SessionEvent::Killed | SessionEvent::Crashed));
139
140 Self::save_session_history(&history, should_archive)?;
141
142 if should_archive {
144 let active_path = Self::session_history_path(session_id, false)?;
145 if active_path.exists() {
146 let _ = fs::remove_file(active_path);
147 }
148 }
149
150 Ok(())
151 }
152
153 pub fn record_session_created(session: &Session) -> Result<()> {
155 let entry = HistoryEntry {
156 session_id: session.id.clone(),
157 session_name: session.name.clone(),
158 event: SessionEvent::Created,
159 timestamp: session.created_at,
160 pid: session.pid,
161 shell: session.shell.clone(),
162 working_dir: session.working_dir.clone(),
163 duration_seconds: None,
164 };
165 Self::add_entry_to_session(&session.id, entry)
166 }
167
168 pub fn record_session_attached(session: &Session) -> Result<()> {
169 let entry = HistoryEntry {
170 session_id: session.id.clone(),
171 session_name: session.name.clone(),
172 event: SessionEvent::Attached,
173 timestamp: Utc::now(),
174 pid: session.pid,
175 shell: session.shell.clone(),
176 working_dir: session.working_dir.clone(),
177 duration_seconds: None,
178 };
179 Self::add_entry_to_session(&session.id, entry)
180 }
181
182 pub fn record_session_detached(session: &Session) -> Result<()> {
183 let entry = HistoryEntry {
184 session_id: session.id.clone(),
185 session_name: session.name.clone(),
186 event: SessionEvent::Detached,
187 timestamp: Utc::now(),
188 pid: session.pid,
189 shell: session.shell.clone(),
190 working_dir: session.working_dir.clone(),
191 duration_seconds: None,
192 };
193 Self::add_entry_to_session(&session.id, entry)
194 }
195
196 pub fn record_session_killed(session: &Session) -> Result<()> {
197 let history = Self::load_session_history(&session.id)?;
198 let duration = if let Some(first_entry) = history.entries.first() {
199 (Utc::now() - first_entry.timestamp).num_seconds()
200 } else {
201 (Utc::now() - session.created_at).num_seconds()
202 };
203
204 let entry = HistoryEntry {
205 session_id: session.id.clone(),
206 session_name: session.name.clone(),
207 event: SessionEvent::Killed,
208 timestamp: Utc::now(),
209 pid: session.pid,
210 shell: session.shell.clone(),
211 working_dir: session.working_dir.clone(),
212 duration_seconds: Some(duration),
213 };
214 Self::add_entry_to_session(&session.id, entry)
215 }
216
217 pub fn record_session_crashed(session: &Session) -> Result<()> {
218 let history = Self::load_session_history(&session.id)?;
219 let duration = if let Some(first_entry) = history.entries.first() {
220 (Utc::now() - first_entry.timestamp).num_seconds()
221 } else {
222 (Utc::now() - session.created_at).num_seconds()
223 };
224
225 let entry = HistoryEntry {
226 session_id: session.id.clone(),
227 session_name: session.name.clone(),
228 event: SessionEvent::Crashed,
229 timestamp: Utc::now(),
230 pid: session.pid,
231 shell: session.shell.clone(),
232 working_dir: session.working_dir.clone(),
233 duration_seconds: Some(duration),
234 };
235 Self::add_entry_to_session(&session.id, entry)
236 }
237
238 pub fn record_session_renamed(
239 session: &Session,
240 old_name: Option<String>,
241 new_name: String,
242 ) -> Result<()> {
243 let entry = HistoryEntry {
244 session_id: session.id.clone(),
245 session_name: Some(new_name.clone()),
246 event: SessionEvent::Renamed {
247 from: old_name,
248 to: new_name,
249 },
250 timestamp: Utc::now(),
251 pid: session.pid,
252 shell: session.shell.clone(),
253 working_dir: session.working_dir.clone(),
254 duration_seconds: None,
255 };
256 Self::add_entry_to_session(&session.id, entry)
257 }
258
259 pub fn load_all_history(
261 include_archived: bool,
262 limit: Option<usize>,
263 ) -> Result<Vec<HistoryEntry>> {
264 let mut all_entries = Vec::new();
265
266 let active_dir = Self::active_history_dir()?;
268 if active_dir.exists() {
269 for entry in fs::read_dir(active_dir)? {
270 let entry = entry?;
271 let path = entry.path();
272 if path.extension().and_then(|s| s.to_str()) == Some("json") {
273 if let Ok(content) = fs::read_to_string(&path) {
274 if let Ok(history) = serde_json::from_str::<SessionHistoryFile>(&content) {
275 all_entries.extend(history.entries);
276 }
277 }
278 }
279 }
280 }
281
282 if include_archived {
284 let archived_dir = Self::archived_history_dir()?;
285 if archived_dir.exists() {
286 for entry in fs::read_dir(archived_dir)? {
287 let entry = entry?;
288 let path = entry.path();
289 if path.extension().and_then(|s| s.to_str()) == Some("json") {
290 if let Ok(content) = fs::read_to_string(&path) {
291 if let Ok(history) =
292 serde_json::from_str::<SessionHistoryFile>(&content)
293 {
294 all_entries.extend(history.entries);
295 }
296 }
297 }
298 }
299 }
300 }
301
302 all_entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
304
305 if let Some(limit) = limit {
307 all_entries.truncate(limit);
308 }
309
310 Ok(all_entries)
311 }
312
313 pub fn get_session_history(session_id: &str) -> Result<Vec<HistoryEntry>> {
315 let history = Self::load_session_history(session_id)?;
316 Ok(history.entries)
317 }
318
319 pub fn cleanup_old_history(days_to_keep: i64) -> Result<usize> {
321 let archived_dir = Self::archived_history_dir()?;
322 let cutoff = Utc::now() - Duration::days(days_to_keep);
323 let mut removed_count = 0;
324
325 if archived_dir.exists() {
326 for entry in fs::read_dir(archived_dir)? {
327 let entry = entry?;
328 let path = entry.path();
329 if path.extension().and_then(|s| s.to_str()) == Some("json") {
330 if let Ok(content) = fs::read_to_string(&path) {
331 if let Ok(history) = serde_json::from_str::<SessionHistoryFile>(&content) {
332 if history.entries.iter().all(|e| e.timestamp < cutoff) {
334 fs::remove_file(&path)?;
335 removed_count += 1;
336 }
337 }
338 }
339 }
340 }
341 }
342
343 Ok(removed_count)
344 }
345
346 pub fn format_duration(seconds: i64) -> String {
347 let hours = seconds / 3600;
348 let minutes = (seconds % 3600) / 60;
349 let secs = seconds % 60;
350
351 if hours > 0 {
352 format!("{}h {}m {}s", hours, minutes, secs)
353 } else if minutes > 0 {
354 format!("{}m {}s", minutes, secs)
355 } else {
356 format!("{}s", secs)
357 }
358 }
359
360 pub fn migrate_from_single_file() -> Result<()> {
362 let old_file = directories::BaseDirs::new()
363 .ok_or_else(|| {
364 NdsError::DirectoryCreationError("Could not find home directory".to_string())
365 })?
366 .home_dir()
367 .join(".nds")
368 .join("history.json");
369
370 if !old_file.exists() {
371 return Ok(()); }
373
374 let content = fs::read_to_string(&old_file)?;
376 if let Ok(old_history) = serde_json::from_str::<crate::history::SessionHistory>(&content) {
377 let mut sessions: HashMap<String, Vec<HistoryEntry>> = HashMap::new();
379
380 for old_entry in old_history.entries {
381 let entry = HistoryEntry {
382 session_id: old_entry.session_id.clone(),
383 session_name: old_entry.session_name,
384 event: match old_entry.event {
385 crate::history::SessionEvent::Created => SessionEvent::Created,
386 crate::history::SessionEvent::Attached => SessionEvent::Attached,
387 crate::history::SessionEvent::Detached => SessionEvent::Detached,
388 crate::history::SessionEvent::Killed => SessionEvent::Killed,
389 crate::history::SessionEvent::Crashed => SessionEvent::Crashed,
390 crate::history::SessionEvent::Renamed { from, to } => {
391 SessionEvent::Renamed { from, to }
392 }
393 },
394 timestamp: old_entry.timestamp,
395 pid: old_entry.pid,
396 shell: old_entry.shell,
397 working_dir: old_entry.working_dir,
398 duration_seconds: old_entry.duration_seconds,
399 };
400
401 sessions
402 .entry(old_entry.session_id.clone())
403 .or_insert_with(Vec::new)
404 .push(entry);
405 }
406
407 for (session_id, entries) in sessions {
409 let is_terminated = entries
410 .iter()
411 .any(|e| matches!(e.event, SessionEvent::Killed | SessionEvent::Crashed));
412
413 let created_at = entries
414 .first()
415 .map(|e| e.timestamp)
416 .unwrap_or_else(Utc::now);
417
418 let history_file = SessionHistoryFile {
419 session_id: session_id.clone(),
420 created_at,
421 entries,
422 };
423
424 Self::save_session_history(&history_file, is_terminated)?;
425 }
426
427 let backup_path = old_file.with_extension("json.backup");
429 fs::rename(old_file, backup_path)?;
430 }
431
432 Ok(())
433 }
434}