detached_shell/
history_v2.rs

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>, // For Killed/Crashed events
30}
31
32// Individual session history stored in separate files
33#[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
40// Main history manager
41pub struct SessionHistory;
42
43impl SessionHistory {
44    // Directory structure: ~/.nds/history/active/ and ~/.nds/history/archived/
45    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    // Get history file path for a session
85    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    // Load history for a specific session
95    pub fn load_session_history(session_id: &str) -> Result<SessionHistoryFile> {
96        // Try active first, then archived
97        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            // Create new history file for this session
106            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    // Save history for a specific session
122    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    // Add an entry to a session's history
130    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        // Determine if this should be archived (session ended)
135        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 archived, remove from active directory
143        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    // Record session events
154    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    // Get all history entries (from all sessions)
260    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        // Load from active sessions
267        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        // Load from archived sessions if requested
283        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        // Sort by timestamp (newest first)
303        all_entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
304
305        // Apply limit if specified
306        if let Some(limit) = limit {
307            all_entries.truncate(limit);
308        }
309
310        Ok(all_entries)
311    }
312
313    // Get history for a specific session
314    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    // Clean up old archived history (older than specified days)
320    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                            // Check if all entries are older than cutoff
333                            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    // Migrate from old single-file format to new per-session format
361    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(()); // Nothing to migrate
372        }
373
374        // Read old format
375        let content = fs::read_to_string(&old_file)?;
376        if let Ok(old_history) = serde_json::from_str::<crate::history::SessionHistory>(&content) {
377            // Group entries by session ID
378            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            // Save each session to its own file
408            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            // Rename old file to .backup
428            let backup_path = old_file.with_extension("json.backup");
429            fs::rename(old_file, backup_path)?;
430        }
431
432        Ok(())
433    }
434}